From ff8585b7dc77c433d30036e86a15b6aff2ca7c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Fri, 5 Jun 2026 22:46:05 +0800 Subject: [PATCH 1/4] feat: add claude-style memory recall --- src/iac_code/acp/server.py | 12 +- src/iac_code/acp/session.py | 12 +- src/iac_code/acp/slash_registry.py | 19 +- src/iac_code/agent/agent_loop.py | 413 ++++++- src/iac_code/agent/agent_tool.py | 3 + src/iac_code/agent/message.py | 58 + src/iac_code/agent/system_prompt.py | 55 +- src/iac_code/commands/__init__.py | 25 +- src/iac_code/commands/memory.py | 166 ++- src/iac_code/commands/prompt.py | 694 +++++++++++ src/iac_code/commands/status.py | 92 ++ .../i18n/locales/de/LC_MESSAGES/messages.po | 494 +++++++- .../i18n/locales/es/LC_MESSAGES/messages.po | 496 +++++++- .../i18n/locales/fr/LC_MESSAGES/messages.po | 496 +++++++- .../i18n/locales/ja/LC_MESSAGES/messages.po | 476 +++++++- .../i18n/locales/pt/LC_MESSAGES/messages.po | 496 +++++++- .../i18n/locales/zh/LC_MESSAGES/messages.po | 474 +++++++- src/iac_code/memory/memory_manager.py | 24 + src/iac_code/memory/memory_tools.py | 24 +- src/iac_code/memory/project_memory.py | 133 +++ src/iac_code/memory/recall.py | 446 +++++++ src/iac_code/providers/anthropic_provider.py | 36 +- src/iac_code/providers/base.py | 1 + src/iac_code/providers/dashscope_provider.py | 23 +- src/iac_code/providers/manager.py | 23 +- src/iac_code/providers/openai_provider.py | 4 +- src/iac_code/services/agent_factory.py | 35 +- src/iac_code/services/context_manager.py | 27 +- src/iac_code/services/session_index.py | 17 + src/iac_code/skills/skill_tool.py | 3 + src/iac_code/ui/dialogs/memory.py | 150 +++ src/iac_code/ui/dialogs/memory_editor.py | 341 ++++++ src/iac_code/ui/dialogs/resume_picker.py | 4 +- src/iac_code/ui/renderer.py | 6 +- src/iac_code/ui/repl.py | 101 +- .../ui/suggestions/command_provider.py | 22 +- tests/acp/test_scenarios.py | 16 +- tests/acp/test_server_coverage.py | 11 + tests/acp/test_sessions.py | 4 +- tests/acp/test_slash_registry.py | 27 +- tests/agent/test_agent_loop_new.py | 1037 ++++++++++++++++- tests/agent/test_message_metadata.py | 30 + tests/agent/test_system_prompt.py | 86 ++ tests/cli/test_headless.py | 26 +- tests/commands/test_memory.py | 346 +++++- tests/commands/test_prompt.py | 168 +++ tests/commands/test_status.py | 150 +++ tests/memory/test_memory_tools.py | 18 + tests/memory/test_project_memory.py | 107 ++ tests/memory/test_recall.py | 550 +++++++++ tests/providers/test_anthropic_provider.py | 38 + tests/providers/test_dashscope_provider.py | 41 + tests/services/test_agent_factory.py | 77 ++ tests/services/test_context_manager.py | 40 + tests/services/test_session_index.py | 38 +- tests/services/test_session_storage.py | 21 +- tests/skills/test_command_registry.py | 18 + tests/test_i18n.py | 2 +- tests/ui/dialogs/test_memory_dialog.py | 101 ++ tests/ui/dialogs/test_memory_editor.py | 209 ++++ tests/ui/dialogs/test_resume_picker.py | 21 +- tests/ui/suggestions/test_aggregator.py | 10 +- tests/ui/suggestions/test_command_provider.py | 37 +- tests/ui/test_renderer_helpers.py | 18 + tests/ui/test_repl_integration.py | 82 ++ tests/ui/test_repl_status.py | 55 +- website/docs/cli/commands.md | 14 +- website/docs/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + .../current/cli/commands.md | 14 +- .../current/cli/interactive-mode.md | 2 +- .../configuration/runtime-configuration.md | 18 + 87 files changed, 9173 insertions(+), 350 deletions(-) create mode 100644 src/iac_code/commands/prompt.py create mode 100644 src/iac_code/memory/project_memory.py create mode 100644 src/iac_code/memory/recall.py create mode 100644 src/iac_code/ui/dialogs/memory.py create mode 100644 src/iac_code/ui/dialogs/memory_editor.py create mode 100644 tests/agent/test_message_metadata.py create mode 100644 tests/commands/test_prompt.py create mode 100644 tests/memory/test_project_memory.py create mode 100644 tests/memory/test_recall.py create mode 100644 tests/ui/dialogs/test_memory_dialog.py create mode 100644 tests/ui/dialogs/test_memory_editor.py diff --git a/src/iac_code/acp/server.py b/src/iac_code/acp/server.py index 6ccf006..b3866d9 100644 --- a/src/iac_code/acp/server.py +++ b/src/iac_code/acp/server.py @@ -31,6 +31,10 @@ logger = logging.getLogger(__name__) +def _runtime_command_memory_manager(runtime: object) -> object | None: + return getattr(runtime, "legacy_memory_manager", None) or getattr(runtime, "memory_manager", None) + + class ACPServer: def __init__(self) -> None: self.conn: acp.Client | None = None @@ -161,7 +165,7 @@ async def new_session( self.conn, mcp_configs=mcp_configs, metrics=self.metrics, - memory_manager=getattr(runtime, "memory_manager", None), + memory_manager=_runtime_command_memory_manager(runtime), ) self.sessions[session.id] = session self.metrics.record_session_created() @@ -303,7 +307,7 @@ async def load_session( self.conn, mcp_configs=mcp_configs, metrics=self.metrics, - memory_manager=getattr(runtime, "memory_manager", None), + memory_manager=_runtime_command_memory_manager(runtime), ) self.sessions[session_id] = session self.metrics.record_session_created() @@ -372,7 +376,7 @@ async def fork_session( self.conn, mcp_configs=mcp_configs, metrics=self.metrics, - memory_manager=getattr(runtime, "memory_manager", None), + memory_manager=_runtime_command_memory_manager(runtime), ) self.sessions[new_session_id] = session self.metrics.record_session_created() @@ -482,7 +486,7 @@ async def resume_session( self.conn, mcp_configs=mcp_configs, metrics=self.metrics, - memory_manager=getattr(runtime, "memory_manager", None), + memory_manager=_runtime_command_memory_manager(runtime), ) self.sessions[resolved_session_id] = session self.metrics.record_session_created() diff --git a/src/iac_code/acp/session.py b/src/iac_code/acp/session.py index d5d1a28..048b74b 100644 --- a/src/iac_code/acp/session.py +++ b/src/iac_code/acp/session.py @@ -18,7 +18,14 @@ from iac_code.acp.state import TurnState from iac_code.acp.tools import ACPTerminalBashTool from iac_code.acp.types import ACPContentBlock -from iac_code.agent.message import Message, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock +from iac_code.agent.message import ( + Message, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + is_recalled_memory_message, +) from iac_code.services.telemetry import use_session_id from iac_code.state.app_state import lookup_permission, record_permission from iac_code.types.permissions import PermissionDecision @@ -64,6 +71,9 @@ def _history_message_to_updates(msg: Message) -> list[Any]: ``ToolCallProgress``. * **user** tool-result blocks are emitted as completed ``ToolCallProgress``. """ + if is_recalled_memory_message(msg): + return [] + updates: list[Any] = [] content = msg.content diff --git a/src/iac_code/acp/slash_registry.py b/src/iac_code/acp/slash_registry.py index bdcdc50..837951c 100644 --- a/src/iac_code/acp/slash_registry.py +++ b/src/iac_code/acp/slash_registry.py @@ -1,8 +1,9 @@ """ACP slash command registry. Manages commands supported over the ACP protocol. -Only /compact, /clear, /debug, /memory, and /rename are allowed; -all other slash commands are rejected with a clear message. +Only /compact, /clear, /debug, /memory-folder, and /rename are executable; +all other slash commands are rejected with a clear message listing public +commands only. """ from __future__ import annotations @@ -15,7 +16,9 @@ logger = logging.getLogger(__name__) -ACP_SUPPORTED_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug", "memory", "rename"}) +ACP_EXECUTABLE_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug", "memory-folder", "rename"}) +ACP_PUBLIC_COMMANDS: frozenset[str] = frozenset({"compact", "clear", "debug", "rename"}) +ACP_SUPPORTED_COMMANDS = ACP_EXECUTABLE_COMMANDS class ACPSlashRegistry: @@ -33,16 +36,16 @@ def is_slash_command(self, text: str) -> bool: async def execute(self, text: str, agent_loop, **context) -> str: """Execute a slash command and return the result text. - If the command is not in :data:`ACP_SUPPORTED_COMMANDS`, returns a - rejection message listing available commands. + If the command is not in :data:`ACP_EXECUTABLE_COMMANDS`, returns a + rejection message listing public commands. """ stripped = text.strip() parts = stripped[1:].split(None, 1) cmd_name = parts[0].lower() if parts else "" args_str = parts[1] if len(parts) > 1 else "" - if cmd_name not in ACP_SUPPORTED_COMMANDS: - supported = ", ".join(f"/{c}" for c in sorted(ACP_SUPPORTED_COMMANDS)) + if cmd_name not in ACP_EXECUTABLE_COMMANDS: + supported = ", ".join(f"/{c}" for c in sorted(ACP_PUBLIC_COMMANDS)) return _("Command '/{cmd_name}' is not supported over ACP. Supported commands: {supported}").format( cmd_name=cmd_name, supported=supported ) @@ -53,7 +56,7 @@ async def execute(self, text: str, agent_loop, **context) -> str: return await self._handle_clear(agent_loop) if cmd_name == "debug": return self._handle_debug(args_str) - if cmd_name == "memory": + if cmd_name == "memory-folder": return self._handle_memory(args_str, context.get("memory_manager")) if cmd_name == "rename": return self._handle_rename(args_str, agent_loop) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index c694c61..89ae6bf 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -53,6 +53,57 @@ class CompactResult: preserve_recent_turns: int = 0 +def _user_input_to_text(user_input: str | list[ContentBlock]) -> str: + if isinstance(user_input, str): + return user_input + parts: list[str] = [] + for block in user_input: + if getattr(block, "type", None) == "text": + parts.append(getattr(block, "text", "") or "") + return " ".join(part for part in parts if part) + + +def _normalize_memory_filename(filename: Any) -> str: + name = str(filename).strip() + if not name: + return "" + if not name.endswith(".md"): + name = f"{name}.md" + return name + + +def _filter_recalled_memory_content(content: str, selected_files: list[str]) -> str: + keep = [_normalize_memory_filename(filename) for filename in selected_files] + keep = [filename for filename in keep if filename] + if not keep: + return "" + + lines = content.splitlines() + sections: dict[str, list[str]] = {} + current_filename = "" + current_lines: list[str] = [] + for line in lines: + if line.startswith("## "): + if current_filename: + sections[current_filename] = current_lines + current_filename = _normalize_memory_filename(line[3:].strip()) + current_lines = [line] + continue + if current_filename: + current_lines.append(line) + if current_filename: + sections[current_filename] = current_lines + + kept_sections = [sections[filename] for filename in keep if filename in sections] + if len(kept_sections) != len(keep): + return "" + + parts = ["# Recalled Memory"] + for section in kept_sections: + parts.append("\n".join(section).strip()) + return "\n\n".join(part for part in parts if part) + + class AgentLoop: """The main agent execution loop. @@ -74,6 +125,8 @@ def __init__( permission_context: Any = None, # ToolPermissionContext permission_context_getter: Any = None, # Callable[[], ToolPermissionContext | None] auto_trigger_skills: list[Any] | None = None, + memory_recall_service: Any = None, + system_prompt_refresher: Callable[[], str] | None = None, ) -> None: self._provider_manager = provider_manager self.system_prompt = system_prompt @@ -89,6 +142,13 @@ def __init__( self._auto_trigger_skills = auto_trigger_skills or [] self._auto_loaded_skills: set[str] = set() self._current_git_branch: str | None = None + self._memory_recall_service = memory_recall_service + self._recorded_memory_prefetch_ids: set[int] = set() + self._pending_memory_prefetches: list[Any] = [] + self._memory_recall_generation = 0 + self._memory_recall_active_turns = 0 + self._last_provider_request_snapshot: dict[str, Any] | None = None + self._system_prompt_refresher = system_prompt_refresher model_name = "" if hasattr(provider_manager, "get_model_name"): @@ -98,6 +158,7 @@ def __init__( self._sync_tool_definitions() if resume_messages: self.context_manager.load_messages(resume_messages) + self._sync_recall_suppression_from_context() self._tool_executor = ToolExecutor(registry=tool_registry) from iac_code.config import get_config_dir @@ -118,30 +179,303 @@ def set_provider(self, provider_manager: Any, system_prompt: str | None = None) if system_prompt is not None: self.system_prompt = system_prompt self.context_manager.set_system_prompt(system_prompt) - self._sync_tool_definitions() + self._sync_tool_definitions(system_prompt=self.system_prompt if system_prompt is not None else None) def set_auto_trigger_skills(self, skill_commands: list[Any] | None) -> None: """Refresh skills considered for automatic trigger injection.""" self._auto_trigger_skills = list(skill_commands or []) - def _get_tool_definitions(self): + def get_memory_recall_stats(self) -> dict[str, Any]: + if self._memory_recall_service is None: + return { + "total_side_queries": 0, + "successful_side_queries": 0, + "failed_side_queries": 0, + "cancelled_side_queries": 0, + "total_selected_files": 0, + "last_duration_ms": 0, + "last_status": "skipped", + "last_selected_files": [], + "last_side_query_duration_ms": 0, + "last_side_query_status": "skipped", + "last_side_query_selected_files": [], + "last_prompt_preview": "", + "last_response_preview": "", + "last_prompt_chars": 0, + "last_response_chars": 0, + "total_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + "last_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + } + get_snapshot = getattr(self._memory_recall_service, "get_stats_snapshot", None) + if not callable(get_snapshot): + return {} + return dict(get_snapshot()) + + def get_last_provider_request_snapshot(self) -> dict[str, Any]: + if self._last_provider_request_snapshot is None: + return {} + return { + "system_prompt": self._last_provider_request_snapshot.get("system_prompt", ""), + "provider_messages": list(self._last_provider_request_snapshot.get("provider_messages") or []), + "tools": list(self._last_provider_request_snapshot.get("tools") or []), + } + + def _start_memory_prefetch_for_turn(self, user_input: str | list[ContentBlock]) -> Any: + if self._memory_recall_service is None: + return None + query = _user_input_to_text(user_input).strip() + if not query: + return None + self._sync_recall_suppression_from_context() + start_prefetch = getattr(self._memory_recall_service, "start_prefetch", None) + if callable(start_prefetch): + prefetch = start_prefetch(query) + else: + recall = getattr(self._memory_recall_service, "recall", None) + if not callable(recall): + return None + from iac_code.memory.recall import MemoryRecallPrefetch + + prefetch = MemoryRecallPrefetch(asyncio.create_task(recall(query))) + if prefetch is not None: + self._pending_memory_prefetches.append(prefetch) + add_done_callback = getattr(prefetch, "add_done_callback", None) + if callable(add_done_callback): + session_id = self._session_id + generation = self._memory_recall_generation + add_done_callback( + lambda task, handle=prefetch, sid=session_id, gen=generation: self._handle_memory_prefetch_done( + handle, + task, + session_id=sid, + generation=gen, + ) + ) + return prefetch + + def _cancel_pending_memory_prefetches(self) -> None: + for prefetch in list(self._pending_memory_prefetches): + done = getattr(prefetch, "done", None) + cancel = getattr(prefetch, "cancel", None) + if callable(done) and callable(cancel) and not done(): + cancel() + self._pending_memory_prefetches.clear() + self._recorded_memory_prefetch_ids.clear() + + def _sync_recall_suppression_from_context(self) -> None: + if self._memory_recall_service is None: + return + mark_files_surfaced = getattr(self._memory_recall_service, "mark_files_surfaced", None) + if callable(mark_files_surfaced): + mark_files_surfaced(self.context_manager.get_surfaced_memory_files()) + + def _persist_context_messages(self) -> None: + if not self._session_storage: + return + self._session_storage.save( + self._cwd, + self._session_id, + self.context_manager.get_messages(), + git_branch=self._current_git_branch, + ) + + def _inject_recalled_memory_result(self, result: Any) -> bool: + content = str(getattr(result, "content", "") or "").strip() + selected_files = list(getattr(result, "selected_files", None) or []) + if not content or not selected_files: + return False + selected_names = {_normalize_memory_filename(filename) for filename in selected_files} + selected_names.discard("") + if not selected_names: + return False + suppressed: set[str] = set() + get_suppressed_files = getattr(self._memory_recall_service, "get_suppressed_files", None) + if callable(get_suppressed_files): + suppressed = {_normalize_memory_filename(filename) for filename in get_suppressed_files()} + suppressed.discard("") + surfaced = { + _normalize_memory_filename(filename) for filename in self.context_manager.get_surfaced_memory_files() + } + surfaced.discard("") + suppressed |= surfaced + injectable_files = [ + filename + for filename in selected_files + if (normalized := _normalize_memory_filename(filename)) and normalized not in suppressed + ] + if not injectable_files: + return False + if len(injectable_files) != len(selected_files): + content = _filter_recalled_memory_content(content, injectable_files) + if not content: + return False + msg = self.context_manager.add_recalled_memory_message(content, injectable_files) + if self._session_storage: + self._session_storage.append( + self._cwd, + self._session_id, + msg, + git_branch=self._current_git_branch, + ) + self._mark_recalled_files_surfaced(injectable_files) + return True + + async def _consume_ready_memory_prefetches(self, prefetch: Any | None = None) -> None: + await asyncio.sleep(0) + for item in list(self._pending_memory_prefetches): + if prefetch is not None and item is not prefetch: + continue + done = getattr(item, "done", None) + if not callable(done) or not done(): + continue + self._pending_memory_prefetches = [ + pending for pending in self._pending_memory_prefetches if pending is not item + ] + try: + result = item.result() + except asyncio.CancelledError: + self._forget_memory_prefetch(item) + continue + except Exception as exc: + logger.debug("Memory recall prefetch failed: {}", exc) + self._forget_memory_prefetch(item) + continue + self._record_memory_recall_result_usage_once(item, result) + self._inject_recalled_memory_result(result) + self._forget_memory_prefetch(item) + + def _mark_recalled_files_surfaced(self, selected_files: list[str]) -> None: + if self._memory_recall_service is None: + return + mark_files_surfaced = getattr(self._memory_recall_service, "mark_files_surfaced", None) + if not callable(mark_files_surfaced): + return + if selected_files: + mark_files_surfaced(selected_files) + + def _handle_memory_prefetch_done( + self, + prefetch: Any, + task: asyncio.Task, + *, + session_id: str | None = None, + generation: int | None = None, + ) -> None: + if not any(item is prefetch for item in self._pending_memory_prefetches): + return + if session_id is not None and session_id != self._session_id: + self._pending_memory_prefetches = [item for item in self._pending_memory_prefetches if item is not prefetch] + self._forget_memory_prefetch(prefetch) + return + if generation is not None and generation != self._memory_recall_generation: + self._pending_memory_prefetches = [item for item in self._pending_memory_prefetches if item is not prefetch] + self._forget_memory_prefetch(prefetch) + return + try: + result = task.result() + except asyncio.CancelledError: + self._pending_memory_prefetches = [item for item in self._pending_memory_prefetches if item is not prefetch] + self._forget_memory_prefetch(prefetch) + return + except Exception as exc: + logger.debug("Memory recall prefetch usage unavailable: {}", exc) + self._pending_memory_prefetches = [item for item in self._pending_memory_prefetches if item is not prefetch] + self._forget_memory_prefetch(prefetch) + return + self._record_memory_recall_result_usage_once(prefetch, result) + if self._memory_recall_active_turns > 0: + return + self._pending_memory_prefetches = [item for item in self._pending_memory_prefetches if item is not prefetch] + self._inject_recalled_memory_result(result) + self._forget_memory_prefetch(prefetch) + + def _record_memory_recall_result_usage_once(self, prefetch: Any, result: Any) -> None: + prefetch_id = id(prefetch) + if prefetch_id in self._recorded_memory_prefetch_ids: + return + self._recorded_memory_prefetch_ids.add(prefetch_id) + self._record_response_usage(result) + + def _forget_memory_prefetch(self, prefetch: Any) -> None: + self._recorded_memory_prefetch_ids.discard(id(prefetch)) + + def _refresh_system_prompt(self) -> None: + if self._system_prompt_refresher is None: + return + try: + system_prompt = self._system_prompt_refresher() + except Exception as exc: + logger.debug("Failed to refresh system prompt: {}", exc) + return + if not isinstance(system_prompt, str) or system_prompt == self.system_prompt: + return + self.system_prompt = system_prompt + self.context_manager.set_system_prompt(system_prompt) + + def _sync_tool_system_prompt(self, system_prompt: str, tools: list[Any] | None = None) -> None: + if tools is None: + try: + tools = list(self.tool_registry.list_tools()) + except Exception as exc: + logger.debug("Failed to list tools while syncing system prompt: {}", exc) + return + for tool in tools: + setter = getattr(tool, "set_system_prompt", None) + if not callable(setter): + continue + try: + setter(system_prompt) + except Exception as exc: + logger.debug("Failed to sync system prompt to tool {}: {}", getattr(tool, "name", ""), exc) + + def _system_prompt_for_current_turn(self) -> str: + return self.system_prompt + + def _prepare_provider_system_prompt(self) -> str: + self._refresh_system_prompt() + system_prompt = self._system_prompt_for_current_turn() + self.context_manager.set_system_prompt(system_prompt) + return system_prompt + + def _get_tool_definitions(self, tools: list[Any] | None = None): """Convert tool registry to provider ToolDefinition format.""" from iac_code.providers.base import ToolDefinition - tools = [] - for tool in self.tool_registry.list_tools(): - tools.append( + if tools is None: + tools = list(self.tool_registry.list_tools()) + tool_definitions = [] + for tool in tools: + tool_definitions.append( ToolDefinition( name=tool.name, description=tool.description, input_schema=tool.input_schema, ) ) - return tools + return tool_definitions - def _sync_tool_definitions(self): + def _sync_tool_definitions(self, system_prompt: str | None = None): """Refresh context token accounting from the current tool registry.""" - tool_definitions = self._get_tool_definitions() + tools = list(self.tool_registry.list_tools()) + if system_prompt is not None: + self._sync_tool_system_prompt(system_prompt, tools=tools) + tool_definitions = self._get_tool_definitions(tools) self.context_manager.set_tool_definitions(tool_definitions) return tool_definitions @@ -244,6 +578,9 @@ async def run_streaming( first_token_received = False final_text_chunks: list[str] = [] final_stop_reason = "stop" + memory_prefetch = None + turn_cancelled = False + self._memory_recall_active_turns += 1 try: # Refresh the git branch once per turn — branch may change # between turns (user runs git checkout via Bash tool), but @@ -260,10 +597,12 @@ async def run_streaming( Message(role="user", content=user_input), git_branch=self._current_git_branch, ) + memory_prefetch = self._start_memory_prefetch_for_turn(user_input) try: async for event in self._run_streaming_inner( user_input, queued_input_provider=queued_input_provider, + memory_prefetch=memory_prefetch, ): if isinstance(event, TextDeltaEvent) and not first_token_received: first_token_received = True @@ -277,9 +616,16 @@ async def run_streaming( self._record_session_usage(event.usage) yield event except asyncio.CancelledError: + turn_cancelled = True + self._memory_recall_generation += 1 + self._cancel_pending_memory_prefetches() log_event(Events.SESSION_CANCELLED, {"stage": "in_query"}) raise finally: + self._memory_recall_active_turns = max(0, self._memory_recall_active_turns - 1) + if not turn_cancelled: + await self._consume_ready_memory_prefetches() + self.context_manager.set_system_prompt(self.system_prompt) elapsed = time.monotonic() - interaction_started add_metric(Metrics.ACTIVE_TIME_TOTAL, int(elapsed), {}) if should_capture_content_on_span() and final_text_chunks: @@ -291,14 +637,18 @@ async def run_streaming( async def _run_streaming_inner( self, user_input: str | list[ContentBlock], + *, queued_input_provider: Callable[[], list[str]] | None = None, + memory_prefetch: Any = None, ) -> AsyncGenerator[StreamEvent, None]: """Inner streaming loop (called from run_streaming inside the ENTRY span).""" from iac_code.services.telemetry import start_span from iac_code.services.telemetry.names import GenAiAttr, GenAiOperationName, GenAiSpanKind, Spans for _turn in range(self._max_turns): - tool_definitions = self._sync_tool_definitions() + system_prompt = self._prepare_provider_system_prompt() + tool_definitions = self._sync_tool_definitions(system_prompt=system_prompt) + await self._consume_ready_memory_prefetches(memory_prefetch) # Auto-compact if needed if self.context_manager.needs_compaction(): @@ -319,11 +669,19 @@ async def _run_streaming_inner( thinking_chunks: list[str] = [] message_ended = False + provider_messages = self._get_provider_messages() + provider_tools = tool_definitions or None + self._last_provider_request_snapshot = { + "system_prompt": system_prompt, + "provider_messages": list(provider_messages), + "tools": list(provider_tools or []), + } + # Stream from provider async for event in self._provider_manager.stream( - messages=self._get_provider_messages(), - system=self.system_prompt, - tools=tool_definitions if tool_definitions else None, + messages=provider_messages, + system=system_prompt, + tools=provider_tools, ): yield event # Forward all provider events to UI @@ -541,6 +899,7 @@ async def poll_event_queues(): ] for req, result in zip(requests, results): processed = self._result_storage.process(req.id, result.content) + self._mark_read_memory_tool_result(req, result) yield ToolResultEvent( tool_use_id=req.id, @@ -604,6 +963,19 @@ async def _submit_queued_inputs_after_tool_call( ) yield QueuedInputSubmittedEvent(text=text) + def _mark_read_memory_tool_result(self, request: ToolCallRequest, result: ToolResult) -> None: + if request.name != "read_memory" or result.is_error or self._memory_recall_service is None: + return + name = request.input.get("name") + if not isinstance(name, str) or not name.strip(): + return + mark_files_read = getattr(self._memory_recall_service, "mark_files_read", None) + if callable(mark_files_read): + filename = name.strip() + if not filename.endswith(".md"): + filename = f"{filename}.md" + mark_files_read([filename]) + async def _apply_auto_triggers(self, user_input: str | list[ContentBlock]) -> None: if not self._auto_trigger_skills: return @@ -673,6 +1045,8 @@ async def _auto_compact(self) -> CompactionEvent | None: self._record_response_usage(response) if response.text: original, new = self.context_manager.apply_compaction(response.text) + self._sync_recall_suppression_from_context() + self._persist_context_messages() duration_ms = int((time.monotonic() - started) * 1000) log_event( Events.MEMORY_COMPACT_SUCCEEDED, @@ -715,6 +1089,8 @@ async def compact(self) -> CompactResult: self._record_response_usage(response) if response.text: original, compacted = self.context_manager.apply_compaction(response.text) + self._sync_recall_suppression_from_context() + self._persist_context_messages() return CompactResult( status="success", original_tokens=original, @@ -748,6 +1124,9 @@ def replace_session(self, session_id: str, resume_messages: list | None) -> None """ from iac_code.config import get_config_dir + self._cancel_pending_memory_prefetches() + self._memory_recall_generation += 1 + self._last_provider_request_snapshot = None self._session_id = session_id self._current_git_branch = None self._auto_loaded_skills.clear() @@ -755,6 +1134,10 @@ def replace_session(self, session_id: str, resume_messages: list | None) -> None if resume_messages: self.context_manager.load_messages(resume_messages) self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) + reset_recall_stats = getattr(self._memory_recall_service, "reset_stats", None) + if callable(reset_recall_stats): + reset_recall_stats() + self._sync_recall_suppression_from_context() self._result_storage = ResultStorage( storage_dir=os.path.join(str(get_config_dir()), "tool-results", session_id), ) @@ -773,8 +1156,14 @@ def _refresh_git_branch(self) -> None: self._current_git_branch = None def reset(self) -> None: + self._cancel_pending_memory_prefetches() + self._memory_recall_generation += 1 + self._last_provider_request_snapshot = None self._auto_loaded_skills.clear() self.context_manager.reset() + reset_recall_stats = getattr(self._memory_recall_service, "reset_stats", None) + if callable(reset_recall_stats): + reset_recall_stats() @property def session_id(self) -> str: diff --git a/src/iac_code/agent/agent_tool.py b/src/iac_code/agent/agent_tool.py index b1a1791..e3a631f 100644 --- a/src/iac_code/agent/agent_tool.py +++ b/src/iac_code/agent/agent_tool.py @@ -138,6 +138,9 @@ def __init__( self._system_prompt = system_prompt self._permission_context = permission_context + def set_system_prompt(self, system_prompt: str) -> None: + self._system_prompt = system_prompt + @property def name(self) -> str: return "agent" diff --git a/src/iac_code/agent/message.py b/src/iac_code/agent/message.py index e81c5c2..08547cc 100644 --- a/src/iac_code/agent/message.py +++ b/src/iac_code/agent/message.py @@ -53,6 +53,31 @@ class ImageBlock(BaseModel): # Union type for all content blocks ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ThinkingBlock | ImageBlock +RECALLED_MEMORY_METADATA_TYPE = "recalled_memory" +RECALLED_MEMORY_MARKER = "Relevant persistent memories recalled for this conversation" + + +def _normalize_recalled_memory_filenames(filenames: list[str]) -> list[str]: + """Normalize recalled memory filenames while preserving first-seen order.""" + normalized: list[str] = [] + seen: set[str] = set() + for filename in filenames: + name = filename.strip() + if not name or "/" in name or "\\" in name: + continue + if not name.endswith(".md"): + name = f"{name}.md" + if name in seen: + continue + normalized.append(name) + seen.add(name) + return normalized + + +def format_recalled_memory_message(content: str) -> str: + """Format recalled memory content as a hidden system reminder.""" + return "\n{}:\n\n{}\n".format(RECALLED_MEMORY_MARKER, content.strip()) + class Message(BaseModel): """A single message in the conversation.""" @@ -61,6 +86,7 @@ class Message(BaseModel): content: str | list[ContentBlock] token_count: int = 0 elapsed_seconds: float = 0.0 + metadata: dict[str, Any] = Field(default_factory=dict) def get_text(self) -> str: """Extract text content from the message.""" @@ -121,6 +147,38 @@ def to_api_format(self) -> dict: return {"role": self.role, "content": content_list} +def create_recalled_memory_message(content: str, selected_files: list[str]) -> Message: + """Create a hidden user message containing automatically recalled memories.""" + files = _normalize_recalled_memory_filenames(selected_files) + return Message( + role="user", + content=format_recalled_memory_message(content), + metadata={ + "type": RECALLED_MEMORY_METADATA_TYPE, + "source": "auto_memory", + "selected_files": files, + }, + ) + + +def is_recalled_memory_message(message: Message) -> bool: + """Return True when a message was generated for recalled memory context.""" + return message.metadata.get("type") == RECALLED_MEMORY_METADATA_TYPE + + +def get_recalled_memory_files(message: Message) -> list[str]: + """Return normalized selected files for recalled-memory messages.""" + if not is_recalled_memory_message(message): + return [] + + selected_files = message.metadata.get("selected_files") + if not isinstance(selected_files, list): + return [] + + filenames = [filename for filename in selected_files if isinstance(filename, str)] + return _normalize_recalled_memory_filenames(filenames) + + class Conversation(BaseModel): """Manages the conversation message history.""" diff --git a/src/iac_code/agent/system_prompt.py b/src/iac_code/agent/system_prompt.py index a6eaeb2..9206a22 100644 --- a/src/iac_code/agent/system_prompt.py +++ b/src/iac_code/agent/system_prompt.py @@ -131,7 +131,6 @@ def _build_system_section() -> str: def _build_environment_section(cwd: str) -> str: - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") os_info = f"{platform.system()} {platform.release()}" if sys.platform == "win32": shell = "git-bash" @@ -154,7 +153,6 @@ def _build_environment_section(cwd: str) -> str: f"- Platform: {platform.system()} {platform.machine()}", f"- OS Version: {os_info}", f"- Shell: {shell}", - f"- Current time: {now}", f"- Git repository: {is_git_repo}", ] if git_branch: @@ -162,6 +160,11 @@ def _build_environment_section(cwd: str) -> str: return "\n".join(lines) +def _build_current_time_section(current_time: str | None = None) -> str: + now = current_time or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return "# Current Time\n" + f"- Current time: {now}" + + def _build_tools_section() -> str: return ( "# Using Tools\n" @@ -205,9 +208,18 @@ def _build_actions_section() -> str: def _build_project_instructions(cwd: str) -> str: + from iac_code import __release_date__ + + if not __release_date__.strip(): + return "" + instructions: list[str] = [] search_names = ["AGENTS.md", ".iac-code/AGENTS.md"] - current = cwd + current = os.path.abspath(cwd) + from iac_code.utils.project_paths import find_git_worktree_root + + git_root = find_git_worktree_root(current) + stop_at = os.path.normcase(os.path.normpath(str(git_root))) if git_root is not None else "" while True: for name in search_names: path = os.path.join(current, name) @@ -219,6 +231,9 @@ def _build_project_instructions(cwd: str) -> str: instructions.append(f"# Project Instructions (from {path})\n{content}") except (OSError, UnicodeDecodeError): pass + current_normalized = os.path.normcase(os.path.normpath(current)) + if stop_at and current_normalized == stop_at: + break parent = os.path.dirname(current) if parent == current: break @@ -234,6 +249,23 @@ def _build_memory_section(memory_content: str) -> str: return f"# Memory\n{memory_content}" +def _build_memory_context_section(memory_context: object) -> str: + parts: list[str] = [] + instruction_memory = str(getattr(memory_context, "instruction_memory_content", "") or "").strip() + memory_index = str(getattr(memory_context, "memory_index_content", "") or "").strip() + memory_mechanics = str(getattr(memory_context, "memory_mechanics_content", "") or "").strip() + + if instruction_memory: + parts.append(f"## Instruction Memory\n{instruction_memory}") + if memory_index: + parts.append(f"## Project Memory Index\n{memory_index}") + if memory_mechanics: + parts.append(f"## Memory Mechanics\n{memory_mechanics}") + if not parts: + return "" + return "# Memory\n" + "\n\n".join(parts) + + def _build_cloud_config_section() -> str: """Build cloud configuration section showing configured providers and regions.""" try: @@ -270,6 +302,8 @@ def build_system_prompt( cwd: str | None = None, memory_content: str = "", skill_listing: str = "", + memory_context: object | None = None, + current_time: str | None = None, ) -> str: """Build complete system prompt from all sections.""" cwd = cwd or os.getcwd() @@ -282,6 +316,12 @@ def build_system_prompt( builder.add_cached_section("tools", _build_tools_section, priority=85, is_static=True) builder.add_cached_section("doing_tasks", _build_doing_tasks_section, priority=80, is_static=True) builder.add_cached_section("actions", _build_actions_section, priority=75, is_static=True) + builder.add_uncached_section( + "current_time", + lambda: _build_current_time_section(current_time), + priority=72, + is_static=False, + ) project_instructions = _build_project_instructions(cwd) if project_instructions: @@ -301,7 +341,14 @@ def build_system_prompt( is_static=False, ) - if memory_content: + if memory_context is not None and _build_memory_context_section(memory_context): + builder.add_cached_section( + "memory", + lambda: _build_memory_context_section(memory_context), + priority=60, + is_static=False, + ) + elif memory_content: builder.add_cached_section( "memory", lambda: _build_memory_section(memory_content), diff --git a/src/iac_code/commands/__init__.py b/src/iac_code/commands/__init__.py index 0ef1557..b03bd53 100644 --- a/src/iac_code/commands/__init__.py +++ b/src/iac_code/commands/__init__.py @@ -7,8 +7,9 @@ from iac_code.commands.effort import effort_command from iac_code.commands.exit import exit_command from iac_code.commands.help import help_command -from iac_code.commands.memory import memory_command +from iac_code.commands.memory import memory_command, memory_folder_command from iac_code.commands.model import model_command +from iac_code.commands.prompt import prompt_command from iac_code.commands.registry import Command, CommandRegistry, CommandResult, LocalCommand, PromptCommand from iac_code.commands.rename import rename_command from iac_code.commands.resume import resume_command @@ -94,9 +95,27 @@ def create_default_registry() -> CommandRegistry: registry.register( LocalCommand( name="memory", - description=_("View and manage persistent memories"), + description=_("Edit IAC-CODE memory files"), handler=memory_command, + history_mode="session", + ) + ) + registry.register( + LocalCommand( + name="memory-folder", + description=_("View and manage persistent memories"), + handler=memory_folder_command, arg_hint=_("[|search |delete |help]"), + hidden=True, + history_mode="session", + ) + ) + registry.register( + LocalCommand( + name="prompt", + description=_("Export current prompt snapshot"), + handler=prompt_command, + hidden=True, history_mode="session", ) ) @@ -114,7 +133,7 @@ def create_default_registry() -> CommandRegistry: name="rename", description=_("Rename the current session"), handler=rename_command, - arg_hint="", + arg_hint=_(""), history_mode="session", ) ) diff --git a/src/iac_code/commands/memory.py b/src/iac_code/commands/memory.py index 402f14a..e41e031 100644 --- a/src/iac_code/commands/memory.py +++ b/src/iac_code/commands/memory.py @@ -1,15 +1,26 @@ -"""Memory command - view and manage persistent memories.""" +"""Memory commands.""" from __future__ import annotations -from typing import Any +import os +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any +from iac_code.agent.system_prompt import build_system_prompt from iac_code.i18n import _ from iac_code.memory.memory_manager import MemoryManager +from iac_code.memory.project_memory import ProjectMemoryRuntime, is_auto_memory_enabled, save_auto_memory_enabled +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file -MEMORY_USAGE = _("Usage: /memory [|search |delete |help]") +MEMORY_USAGE = _("Usage: /memory-folder [|search |delete |help]") _RESERVED_SUBCOMMANDS = {"search", "delete", "help"} +if TYPE_CHECKING: + from iac_code.ui.dialogs.memory_editor import MemoryEditResult + def _format_summary(title: str, memories: list[dict[str, Any]]) -> str: if not memories: @@ -76,10 +87,155 @@ def execute_memory_command(memory_manager: MemoryManager, args: list[str]) -> st return _format_memory(memory) -async def memory_command(**kwargs) -> str: +async def memory_folder_command(**kwargs) -> str: context = kwargs.get("context") repl = getattr(context, "repl", None) if context is not None else None - memory_manager = getattr(repl, "_memory_manager", None) + memory_manager = getattr(repl, "_legacy_memory_manager", None) or getattr(repl, "_memory_manager", None) if memory_manager is None: return _("Memory manager is unavailable.") return execute_memory_command(memory_manager, kwargs.get("args") or []) + + +async def memory_command(**kwargs) -> str | None: + context = kwargs.get("context") + repl = getattr(context, "repl", None) if context is not None else None + runtime = getattr(repl, "_memory_runtime", None) + if runtime is None: + return _("Memory runtime is unavailable.") + + initial_action: str | None = None + while True: + action = _select_memory_action( + runtime, + auto_memory_enabled=is_auto_memory_enabled(), + initial_action=initial_action, + on_toggle=save_auto_memory_enabled, + ) + if action is None: + return None + + try: + if action == "project": + path = runtime.ensure_instruction_file("project") + result = _edit_memory_file(path, _("Project memory")) + return _handle_instruction_edit_result( + result, + path=path, + refresh_target=repl, + scope_label=_("project memory"), + private_file=False, + ) + if action == "user": + path = runtime.ensure_instruction_file("user") + result = _edit_memory_file(path, _("User memory")) + return _handle_instruction_edit_result( + result, + path=path, + refresh_target=repl, + scope_label=_("user memory"), + private_file=True, + ) + if action == "folder": + path = runtime.ensure_auto_memory_dir() + _open_folder(path) + initial_action = "folder" + continue + except Exception as exc: + return _("Failed to open memory: {error}").format(error=exc) + + +def _select_memory_action( + runtime: ProjectMemoryRuntime, + *, + auto_memory_enabled: bool, + initial_action: str | None = None, + on_toggle: Callable[[bool], None] | None = None, +) -> str | None: + from iac_code.ui.dialogs.memory import MemoryDialog + + return MemoryDialog( + project_path=runtime.project_instruction_path, + user_path=runtime.user_instruction_path, + auto_memory_dir=runtime.auto_memory_dir, + auto_memory_enabled=auto_memory_enabled, + initial_focus_action=initial_action, + on_toggle=on_toggle, + ).run() + + +def _edit_memory_file(path: Path, title: str) -> "MemoryEditResult": + from iac_code.ui.dialogs.memory_editor import VimMemoryEditor + + try: + content = path.read_text(encoding="utf-8") + except OSError: + content = "" + return VimMemoryEditor(content, title=title, path=str(path)).run() + + +def _handle_instruction_edit_result( + result: "MemoryEditResult", + *, + path: Path, + refresh_target: object | None, + scope_label: str, + private_file: bool, +) -> str | None: + if result.status == "cancelled": + return None + if result.status == "unchanged": + return _("No changes made to {scope}: {path}").format(scope=scope_label, path=path) + if result.status == "saved": + if private_file: + ensure_private_dir(path.parent) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(result.content, encoding="utf-8", newline="\n") + if private_file: + ensure_private_file(path) + _refresh_repl_memory_context(refresh_target) + return _("Saved {scope}: {path}").format(scope=scope_label, path=path) + return None + + +def _open_folder(path: Path) -> None: + _open_path(path) + + +def _open_path(path: Path) -> None: + if sys.platform == "darwin": + subprocess.run(["open", str(path)], check=True) + return + if sys.platform == "win32": + os.startfile(path) # type: ignore[attr-defined] + return + subprocess.run(["xdg-open", str(path)], check=True) + + +def _refresh_repl_memory_context(repl: object | None) -> None: + if repl is None: + return + refresh_system_prompt = getattr(repl, "_refresh_system_prompt", None) + if callable(refresh_system_prompt): + refresh_system_prompt() + return + refresh_memory_context = getattr(repl, "_refresh_memory_context", None) + if not callable(refresh_memory_context): + return + memory_context = refresh_memory_context() + agent_loop = getattr(repl, "_agent_loop", None) + provider_manager = getattr(repl, "_provider_manager", None) + if agent_loop is None or provider_manager is None: + return + cwd = getattr(repl, "_original_cwd", os.getcwd()) + skill_listing = getattr(repl, "_skill_listing", "") + current_time = getattr(repl, "_runtime_current_time", None) + agent_loop.set_provider( + provider_manager, + system_prompt=build_system_prompt( + cwd=cwd, + memory_context=memory_context, + skill_listing=skill_listing, + current_time=current_time if isinstance(current_time, str) else None, + ), + ) diff --git a/src/iac_code/commands/prompt.py b/src/iac_code/commands/prompt.py new file mode 100644 index 0000000..22fcd59 --- /dev/null +++ b/src/iac_code/commands/prompt.py @@ -0,0 +1,694 @@ +"""Hidden prompt snapshot export command.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from collections.abc import Mapping +from datetime import datetime +from html import escape +from pathlib import Path +from typing import Any, cast + +from iac_code.agent.message import RECALLED_MEMORY_MARKER +from iac_code.agent.system_prompt import DYNAMIC_BOUNDARY +from iac_code.i18n import _ +from iac_code.utils.file_security import ensure_private_file + + +async def prompt_command(context=None, **kwargs) -> str: + repl = getattr(context, "repl", None) if context is not None else None + if repl is None: + return _("Prompt command requires a REPL context.") + + try: + path = export_prompt_html(repl, output_dir=kwargs.get("output_dir")) + except Exception as exc: + return _("Failed to export prompt: {error}").format(error=exc) + + try: + _open_path(path) + except Exception as exc: + return _("Prompt exported: {path}\nFailed to open it automatically: {error}").format(path=path, error=exc) + + return _("Prompt exported and opened: {path}").format(path=path) + + +def export_prompt_html(repl: object, *, output_dir: Path | str | None = None) -> Path: + snapshot = build_prompt_snapshot(repl) + html = render_prompt_html(snapshot) + directory = Path(output_dir) if output_dir is not None else Path(tempfile.mkdtemp(prefix="iac-code-prompt-")) + directory.mkdir(parents=True, exist_ok=True) + session_id = _safe_filename(str(snapshot["metadata"].get("session_id") or "session")) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + path = directory / f"iac-code-prompt-{session_id}-{timestamp}.html" + path.write_text(html, encoding="utf-8", newline="\n") + ensure_private_file(path) + return path + + +def build_prompt_snapshot(repl: object) -> dict[str, Any]: + agent_loop = getattr(repl, "_agent_loop", None) + if agent_loop is None: + raise RuntimeError(_("Prompt export is only available in interactive mode.")) + + last_request = _last_provider_request(agent_loop) + source = _("Last main-model request") if last_request else _("Current runtime state") + system_prompt = str(last_request.get("system_prompt") or _current_system_prompt(repl, agent_loop)) + provider_messages = ( + list(last_request.get("provider_messages") or []) + if "provider_messages" in last_request + else _provider_messages(agent_loop) + ) + tools = list(last_request.get("tools") or []) if "tools" in last_request else _tool_definitions(agent_loop) + status = _status_snapshot(repl) + metadata = { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "session_id": status.get("session_id") or getattr(agent_loop, "session_id", ""), + "provider": status.get("provider", ""), + "model": status.get("model", ""), + "cwd": status.get("cwd", ""), + "source": source, + } + return { + "metadata": metadata, + "system_prompt": system_prompt, + "system_sections": _split_system_prompt(system_prompt), + "provider_messages": provider_messages, + "tools": tools, + "memory_sections": _memory_sections(repl), + } + + +def render_prompt_html(snapshot: dict[str, Any]) -> str: + metadata = snapshot.get("metadata") or {} + metadata_rows = "\n".join( + _metadata_item(label, str(metadata.get(key, "") or "")) + for label, key in [ + (_("Generated"), "generated_at"), + (_("Session"), "session_id"), + (_("Provider"), "provider"), + (_("Model"), "model"), + (_("CWD"), "cwd"), + (_("Source"), "source"), + ] + ) + system_sections = "\n".join( + _content_card(section["title"], section["content"], badge=section.get("zone", "")) + for section in snapshot.get("system_sections", []) + ) + provider_messages = "\n".join( + _message_card(index, message) for index, message in enumerate(snapshot.get("provider_messages", []), start=1) + ) + tools = "\n".join(_tool_card(tool) for tool in snapshot.get("tools", [])) + raw_system_prompt = _content_card( + _("Raw Full System Prompt"), + str(snapshot.get("system_prompt", "")), + collapsed=True, + ) + all_tab = _render_all_tab(snapshot) + system_tab = "{system_sections}{raw_system_prompt}".format( + system_sections=system_sections or '

{}

'.format(escape(_("System prompt is empty."))), + raw_system_prompt=raw_system_prompt, + ) + messages_tab = provider_messages or '

{}

'.format(escape(_("No provider messages yet."))) + tools_tab = tools or '

{}

'.format(escape(_("No tools are currently registered."))) + return """ + + + + +{html_title} + + + +
+

{page_title}

+

+ {subtitle} +

+ + + {all_panel} + {system_panel} + {messages_panel} + {tools_panel} +
+ + + +""".format( + html_title=escape(_("IAC-CODE Prompt Snapshot")), + page_title=escape(_("Prompt Snapshot")), + subtitle=escape( + _( + "A local diagnostic view of the current main-model prompt state. " + "This export does not trigger memory recall." + ) + ), + tab_aria_label=escape(_("Prompt snapshot sections")), + metadata_rows=metadata_rows, + all_tab_button=_tab_button("all", _("ALL"), selected=True), + system_tab_button=_tab_button("system", _("System Prompt")), + messages_tab_button=_tab_button("messages", _("Provider Messages")), + tools_tab_button=_tab_button("tools", _("Tools")), + all_panel=_tab_panel("all", all_tab, active=True), + system_panel=_tab_panel("system", system_tab), + messages_panel=_tab_panel("messages", messages_tab), + tools_panel=_tab_panel("tools", tools_tab), + ) + + +def _current_system_prompt(repl: object, agent_loop: object) -> str: + builder = getattr(repl, "_build_current_system_prompt", None) + if callable(builder): + prompt = builder() + if isinstance(prompt, str): + return prompt + return str(getattr(agent_loop, "system_prompt", "") or "") + + +def _status_snapshot(repl: object) -> dict[str, Any]: + get_status = getattr(repl, "get_status_snapshot", None) + if not callable(get_status): + return {} + try: + snapshot = get_status() + except Exception: + return {} + return snapshot if isinstance(snapshot, dict) else {} + + +def _provider_messages(agent_loop: object) -> list[dict[str, Any]]: + getter = getattr(agent_loop, "_get_provider_messages", None) + if not callable(getter): + return [] + try: + messages = getter() + except Exception: + return [] + return [_message_snapshot(message) for message in messages] + + +def _last_provider_request(agent_loop: object) -> dict[str, Any]: + getter = getattr(agent_loop, "get_last_provider_request_snapshot", None) + if not callable(getter): + return {} + try: + snapshot = getter() + except Exception: + return {} + if not isinstance(snapshot, dict) or not snapshot: + return {} + return { + "system_prompt": str(snapshot.get("system_prompt") or ""), + "provider_messages": [_message_snapshot(message) for message in snapshot.get("provider_messages") or []], + "tools": [_tool_snapshot(tool) for tool in snapshot.get("tools") or []], + } + + +def _tool_definitions(agent_loop: object) -> list[dict[str, Any]]: + getter = getattr(agent_loop, "_get_tool_definitions", None) + if not callable(getter): + return [] + try: + tools = getter() + except Exception: + return [] + return [_tool_snapshot(tool) for tool in tools] + + +def _tool_snapshot(tool: object) -> dict[str, Any]: + if isinstance(tool, Mapping): + tool_map = cast(Mapping[str, Any], tool) + return { + "name": str(tool_map.get("name") or ""), + "description": str(tool_map.get("description") or ""), + "input_schema": tool_map.get("input_schema") or {}, + } + return { + "name": str(getattr(tool, "name", "") or ""), + "description": str(getattr(tool, "description", "") or ""), + "input_schema": getattr(tool, "input_schema", {}) or {}, + } + + +def _memory_sections(repl: object) -> list[dict[str, str]]: + memory_context = getattr(repl, "_memory_context", None) + sections: list[dict[str, str]] = [] + for title, attr in [ + (_("Instruction Memory"), "instruction_memory_content"), + (_("Project Memory Index"), "memory_index_content"), + (_("Memory Mechanics"), "memory_mechanics_content"), + ]: + content = str(getattr(memory_context, attr, "") or "").strip() + if content: + sections.append({"title": title, "content": content}) + return sections + + +def _message_snapshot(message: object) -> dict[str, Any]: + return { + "role": str(getattr(message, "role", "") or ""), + "content": _content_snapshot(getattr(message, "content", "")), + } + + +def _content_snapshot(content: object) -> object: + if isinstance(content, str): + return content + if not isinstance(content, list): + return str(content) + return [_block_snapshot(block) for block in content] + + +def _block_snapshot(block: object) -> dict[str, Any]: + if isinstance(block, Mapping): + source = cast(Mapping[str, Any], block) + data_value = source.get("data") + result: dict[str, Any] = { + key: value for key, value in source.items() if key != "data" and value not in (None, "") + } + else: + data_value = getattr(block, "data", None) + result: dict[str, Any] = {} + for key in ["type", "text", "tool_use_id", "name", "input", "content", "is_error", "media_type"]: + value = getattr(block, key, None) + if value not in (None, ""): + result[key] = value + if data_value: + result["data"] = _("").format(count=len(str(data_value))) + return result + + +def _split_system_prompt(system_prompt: str) -> list[dict[str, str]]: + sections: list[dict[str, str]] = [] + zone = "static" + title = _("Preamble") + lines: list[str] = [] + + def flush() -> None: + content = "\n".join(lines).strip() + if content: + sections.append({"title": title, "content": content, "zone": zone}) + + for line in system_prompt.splitlines(): + if line.strip() == DYNAMIC_BOUNDARY: + flush() + lines = [] + zone = "dynamic" + title = _("Dynamic Prompt") + continue + if line.startswith("# "): + flush() + title = line[2:].strip() or _("Section") + lines = [line] + continue + lines.append(line) + flush() + return sections + + +def _metadata_item(label: str, value: str) -> str: + return ( + '
{label}
{value}
' + ).format(label=escape(label), value=escape(value)) + + +def _content_card(title: str, content: str, *, badge: str = "", collapsed: bool = False) -> str: + tag = "details" if collapsed else "section" + open_attr = "" if collapsed else "" + if collapsed: + header = "{}".format(_card_header(title, badge)) + else: + header = _card_header(title, badge) + return '<{tag} class="card" {open_attr}>{header}
{content}
'.format( + tag=tag, + open_attr=open_attr, + header=header, + content=escape(content), + ) + + +def _tab_button(tab_id: str, label: str, *, selected: bool = False) -> str: + return ( + '' + ).format( + selected="true" if selected else "false", + tab_id=escape(tab_id), + label=escape(label), + ) + + +def _tab_panel(tab_id: str, content: str, *, active: bool = False) -> str: + classes = "tab-panel active" if active else "tab-panel" + return '
{content}
'.format( + classes=classes, + tab_id=escape(tab_id), + content=content, + ) + + +def _render_all_tab(snapshot: dict[str, Any]) -> str: + metadata = snapshot.get("metadata") or {} + system_sections = list(snapshot.get("system_sections") or []) + provider_messages = list(snapshot.get("provider_messages") or []) + tools = list(snapshot.get("tools") or []) + has_recalled_memory = any(_is_recalled_memory_content(message.get("content", "")) for message in provider_messages) + recalled_line = ( + _("Present in Provider Messages as a hidden conversation .") + if has_recalled_memory + else _("Not present in this snapshot.") + ) + assembly = "\n".join( + [ + _("Source: {source}").format(source=metadata.get("source") or _("Current runtime state")), + "", + _("1. System Prompt"), + _(" Provider field: system"), + _(" Details: System Prompt tab"), + _(" Sections: {count}").format(count=len(system_sections)), + "", + _("2. Provider Messages"), + _(" Provider field: messages"), + _(" Details: Provider Messages tab"), + _(" Messages: {count}").format(count=len(provider_messages)), + _(" Recalled memory: {status}").format(status=recalled_line), + "", + _("3. Tools"), + _(" Provider field: tools"), + _(" Details: Tools tab"), + _(" Tools: {count}").format(count=len(tools)), + ] + ) + return "{steps}{summary}".format( + steps=( + '
' + + _assembly_step( + _("1. System Prompt"), + "system", + _("System Prompt"), + _("Provider system parameter. This is sent before provider messages."), + _("{count} sections").format(count=len(system_sections)), + ) + + _assembly_step( + _("2. Provider Messages"), + "messages", + _("Provider Messages"), + _("Conversation messages in send order. Hidden conversation recalled memory appears here."), + _("{count} messages; recalled memory {status}").format( + count=len(provider_messages), + status=_("present") if has_recalled_memory else _("not present"), + ), + ) + + _assembly_step( + _("3. Tools"), + "tools", + _("Tools"), + _("Tool definitions available to the main model for this request."), + _("{count} tools").format(count=len(tools)), + ) + + "
" + ), + summary=_content_card(_("Prompt Assembly Order"), assembly), + ) + + +def _assembly_step(title: str, tab_id: str, tab_label: str, body: str, meta: str) -> str: + return ( + '
' + '
{title}
' + '
{body} ' + '' + "
" + '
{meta}
' + "
" + ).format( + title=escape(title), + body=escape(body), + tab_id=escape(tab_id), + button_label=escape(_("Open {tab_label}").format(tab_label=tab_label)), + meta=escape(meta), + ) + + +def _message_card(index: int, message: dict[str, Any]) -> str: + role = str(message.get("role") or _("message")) + content = message.get("content", "") + if not isinstance(content, str): + content = json.dumps(content, indent=2, ensure_ascii=False) + badge = _("recalled memory") if _is_recalled_memory_content(message.get("content", "")) else _("message") + return _content_card("#{index} {role}".format(index=index, role=role), content, badge=badge) + + +def _is_recalled_memory_content(content: object) -> bool: + if isinstance(content, str): + return RECALLED_MEMORY_MARKER in content + if isinstance(content, list): + for block in content: + if isinstance(block, Mapping): + block_map = cast(Mapping[str, Any], block) + if _is_recalled_memory_content(block_map.get("text") or block_map.get("content") or ""): + return True + elif _is_recalled_memory_content(getattr(block, "text", None) or getattr(block, "content", None) or ""): + return True + return False + + +def _tool_card(tool: dict[str, Any]) -> str: + content = "{description}\n\n{schema_label}:\n{schema}".format( + description=tool.get("description", ""), + schema_label=_("Input schema"), + schema=json.dumps(tool.get("input_schema") or {}, indent=2, ensure_ascii=False), + ) + return _content_card(str(tool.get("name") or _("tool")), content, badge=_("tool")) + + +def _card_header(title: str, badge: str = "") -> str: + badge_html = '{}'.format(escape(badge)) if badge else "" + return '
{title}{badge}
'.format( + title=escape(title), + badge=badge_html, + ) + + +def _safe_filename(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in value.strip()) + return cleaned[:80] or "session" + + +def _open_path(path: Path) -> None: + if sys.platform == "darwin": + subprocess.run(["open", str(path)], check=True) + return + if sys.platform == "win32": + os.startfile(path) # type: ignore[attr-defined] + return + subprocess.run(["xdg-open", str(path)], check=True) diff --git a/src/iac_code/commands/status.py b/src/iac_code/commands/status.py index 51538bc..edc5702 100644 --- a/src/iac_code/commands/status.py +++ b/src/iac_code/commands/status.py @@ -51,12 +51,66 @@ def _render_status_panel(snapshot: dict[str, Any]) -> Panel: text.append("\n") text.append("\n") + memory_recall = snapshot.get("memory_recall") + if _should_show_memory_recall() and isinstance(memory_recall, dict) and memory_recall: + _append_memory_recall(text, memory_recall) + text.append("\n") + _append_line(text, _("Turns"), "{} / {}".format(snapshot.get("turn_count", 0), snapshot.get("max_turns", 0))) _append_line(text, _("Context"), _format_context(snapshot.get("context_usage") or {})) return Panel(Group(text), title=_("Session Status"), border_style="cyan", expand=False) +def _should_show_memory_recall() -> bool: + from iac_code.utils.log import is_debug_enabled + + return is_debug_enabled() + + +def _append_memory_recall(text: Text, memory_recall: dict[str, Any]) -> None: + text.append(_("Memory Recall"), style="bold") + text.append("\n") + _append_line( + text, + _("Side queries"), + _("{total} total, {success} success, {failed} failed, {cancelled} cancelled").format( + total=int(memory_recall.get("total_side_queries") or 0), + success=int(memory_recall.get("successful_side_queries") or 0), + failed=int(memory_recall.get("failed_side_queries") or 0), + cancelled=int(memory_recall.get("cancelled_side_queries") or 0), + ), + indent=2, + ) + last_attempt_files = [str(item) for item in memory_recall.get("last_selected_files") or []] + _append_line( + text, + _("Last attempt"), + _("{status} in {duration} ms, {count} files selected").format( + status=str(memory_recall.get("last_status") or "skipped"), + duration=int(memory_recall.get("last_duration_ms") or 0), + count=len(last_attempt_files), + ), + indent=2, + ) + last_side_query_files = [str(item) for item in memory_recall.get("last_side_query_selected_files") or []] + if int(memory_recall.get("total_side_queries") or 0) > 0: + _append_line( + text, + _("Last side call"), + _("{status} in {duration} ms, {count} files selected").format( + status=str(memory_recall.get("last_side_query_status") or "skipped"), + duration=int(memory_recall.get("last_side_query_duration_ms") or 0), + count=len(last_side_query_files), + ), + indent=2, + ) + if last_side_query_files: + _append_line(text, _("Last files"), ", ".join(last_side_query_files[:3]), indent=2) + _append_memory_usage(text, _("Side call usage"), memory_recall.get("total_usage"), indent=2, include_events=True) + _append_memory_usage(text, _("Last usage"), memory_recall.get("last_usage"), indent=2) + + def _append_line(text: Text, label: str, value: str, *, indent: int = 0) -> None: label_text = label + ":" padding = max(0, LABEL_COLUMN_WIDTH - cell_len(label_text)) @@ -67,6 +121,44 @@ def _append_line(text: Text, label: str, value: str, *, indent: int = 0) -> None text.append("\n") +def _append_block(text: Text, value: str, *, indent: int = 0) -> None: + prefix = " " * indent + for line in value.splitlines(): + text.append(prefix) + text.append(line, style="dim") + text.append("\n") + + +def _append_memory_usage( + text: Text, + label: str, + usage: Any, + *, + indent: int = 0, + include_events: bool = False, +) -> None: + if not isinstance(usage, dict) or not usage.get("has_recorded_usage"): + _append_line(text, label, _("No token usage reported"), indent=indent) + return + + if include_events: + value = _("{events} records, input {input}, output {output}, cache read {cache_read}, total {total}").format( + events=_format_int(int(usage.get("recorded_events") or 0)), + input=_format_int(int(usage.get("input_tokens") or 0)), + output=_format_int(int(usage.get("output_tokens") or 0)), + cache_read=_format_int(int(usage.get("cache_read_input_tokens") or 0)), + total=_format_int(int(usage.get("total_tokens") or 0)), + ) + else: + value = _("input {input}, output {output}, cache read {cache_read}, total {total}").format( + input=_format_int(int(usage.get("input_tokens") or 0)), + output=_format_int(int(usage.get("output_tokens") or 0)), + cache_read=_format_int(int(usage.get("cache_read_input_tokens") or 0)), + total=_format_int(int(usage.get("total_tokens") or 0)), + ) + _append_line(text, label, value, indent=indent) + + def _session_display(snapshot: dict[str, Any]) -> str: session_id = snapshot.get("session_id") or "" if snapshot.get("resumed"): diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index f70d650..32d321d 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -819,6 +819,10 @@ msgstr "Beim LLM-Anbieter authentifizieren" msgid "Toggle debug logging" msgstr "Debug-Protokollierung umschalten" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "IAC-CODE-Speicherdateien bearbeiten" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "Persistente Erinnerungen anzeigen und verwalten" @@ -827,6 +831,10 @@ msgstr "Persistente Erinnerungen anzeigen und verwalten" msgid "[|search |delete |help]" msgstr "[|search |delete |help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "Aktuellen Prompt-Snapshot exportieren" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "Eine frühere Sitzung fortsetzen" @@ -839,6 +847,10 @@ msgstr "[Konversations-ID oder Suchbegriff]" msgid "Rename the current session" msgstr "Aktuelle Sitzung umbenennen" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "Skills verwalten" @@ -1223,14 +1235,14 @@ msgid "Exit" msgstr "Beenden" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "Verwendung: /memory [|search |delete |help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "Verwendung: /memory-folder [|search |delete |help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "Gespeicherte Erinnerungen:" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "Noch keine Erinnerungen gespeichert." @@ -1242,7 +1254,7 @@ msgstr "Passende Erinnerungen:" msgid "No matching memories." msgstr "Keine passenden Erinnerungen." -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "Erinnerung '{name}' nicht gefunden." @@ -1252,6 +1264,41 @@ msgstr "Erinnerung '{name}' nicht gefunden." msgid "Memory '{name}' deleted." msgstr "Erinnerung '{name}' gelöscht." +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "Speicher-Laufzeit ist nicht verfügbar." + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "Projektspeicher" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "Projektspeicher" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "Benutzerspeicher" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "Benutzerspeicher" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "Speicher konnte nicht geöffnet werden: {error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "Keine Änderungen an {scope}: {path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "{scope} gespeichert: {path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1276,6 +1323,284 @@ msgstr "Aktuelles Modell: {model}" msgid "Kept model as {model}" msgstr "Modell beibehalten: {model}" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "Der Prompt-Befehl benötigt einen REPL-Kontext." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "Prompt konnte nicht exportiert werden: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"Prompt exportiert: {path}\n" +"Automatisches Öffnen fehlgeschlagen: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "Prompt exportiert und geöffnet: {path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "Prompt-Export ist nur im interaktiven Modus verfügbar." + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "Letzte Anfrage an das Hauptmodell" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "Aktueller Laufzeitzustand" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "Erzeugt" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "Sitzung" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "Anbieter" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "Modell" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "Aktuelles Verzeichnis" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "Quelle" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "Vollständiger roher System-Prompt" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "Der System-Prompt ist leer." + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "Noch keine Anbieternachrichten." + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "Derzeit sind keine Tools registriert." + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "IAC-CODE-Prompt-Snapshot" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "Prompt-Snapshot" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "" +"Lokale Diagnoseansicht des aktuellen Prompt-Zustands des Hauptmodells. " +"Dieser Export löst keinen Speicherabruf aus." + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "Abschnitte des Prompt-Snapshots" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "ALLE" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "System-Prompt" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "Anbieternachrichten" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "Tools" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "Anweisungsspeicher" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "Projektspeicherindex" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "Speichermechanik" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<{count} Zeichen ausgelassen>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "Einleitung" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "Dynamischer Prompt" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "Abschnitt" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "" +"In Anbieternachrichten als versteckte Unterhaltung " +"vorhanden." + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "In diesem Snapshot nicht vorhanden." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "Quelle: {source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. System-Prompt" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " Anbieterfeld: system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " Details: Registerkarte System-Prompt" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " Abschnitte: {count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. Anbieternachrichten" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " Anbieterfeld: messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " Details: Registerkarte Anbieternachrichten" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " Nachrichten: {count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " Abgerufener Speicher: {status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. Tools" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " Anbieterfeld: tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " Details: Registerkarte Tools" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " Tools: {count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "" +"System-Parameter des Anbieters. Er wird vor den Anbieternachrichten " +"gesendet." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} Abschnitte" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "" +"Unterhaltungsnachrichten in Sendereihenfolge. Versteckt abgerufener " +"Gesprächsspeicher erscheint hier." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} Nachrichten; abgerufener Speicher {status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "vorhanden" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "nicht vorhanden" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "Für diese Anfrage verfügbare Tooldefinitionen für das Hauptmodell." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} Tools" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "Prompt-Zusammenstellungsreihenfolge" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "{tab_label} öffnen" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "Nachricht" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "abgerufener Speicher" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "Eingabeschema" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "Tool" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "Umbenennen ist nur im interaktiven Modus verfügbar." @@ -1330,26 +1655,10 @@ msgstr "Der Befehl status benötigt einen REPL-Kontext." msgid "Status is only available in interactive mode." msgstr "status ist nur im interaktiven Modus verfügbar." -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "Sitzung" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "Anbieter" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "nicht konfiguriert" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "Modell" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "Aktuelles Verzeichnis" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "API-Token-Nutzung (aufgezeichnet):" @@ -1386,6 +1695,66 @@ msgstr "Kontext" msgid "Session Status" msgstr "Sitzungsstatus" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "Speicherabruf" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Nebenabfragen" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "" +"{total} insgesamt, {success} erfolgreich, {failed} fehlgeschlagen, " +"{cancelled} abgebrochen" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "Letzter Versuch" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status} in {duration} ms, {count} Dateien ausgewaehlt" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "Letzter Nebenaufruf" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "Letzte Dateien" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "Nutzung des Nebenaufrufs" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "Letzte Nutzung" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "Keine Token-Nutzung gemeldet" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "" +"{events} Eintraege, Eingabe {input}, Ausgabe {output}, Cache-Lesezugriff " +"{cache_read}, gesamt {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "" +"Eingabe {input}, Ausgabe {output}, Cache-Lesezugriff {cache_read}, gesamt" +" {total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1435,6 +1804,40 @@ msgstr "(dynamisch)" msgid "Aborted!" msgstr "Abgebrochen!" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "" +"Persistente Speicher lesen. name weglassen, um alle aufzulisten, oder " +"name angeben, um einen bestimmten Speicher zu lesen." + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "Name des zu lesenden Speichers. Weglassen, um alle aufzulisten." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "" +"Einen persistenten Speicher speichern. Verwenden, wenn der Benutzer " +"ausdrücklich darum bittet, Informationen zu merken oder aufzubewahren. " +"Wählen Sie einen knappen, stabilen Namen, einen passenden Typ, eine kurze" +" Beschreibung und den nützlichen Inhalt. Typen: {types}." + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "Auto-Speicher ist ausgeschaltet." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "Speicher '{name}' gespeichert." + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2727,6 +3130,49 @@ msgstr "Kein Konversationsverlauf" msgid "Message Preview" msgstr "Nachrichtenvorschau" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "Speicher" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "Auto-Speicher: {state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "aktiviert" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "deaktiviert" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Enter zum Bestätigen · Esc zum Abbrechen" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "Gespeichert in {path}" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "Auto-Speicherordner oeffnen" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "EINFÜGEN" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "NORMAL" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq speichern · :q! verwerfen" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "Datei öffnen" @@ -2869,14 +3315,6 @@ msgstr "Keine Skills gefunden" msgid "Bundled skills cannot be disabled." msgstr "Gebündelte Skills können nicht deaktiviert werden." -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "aktiviert" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "deaktiviert" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "gesperrt" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index d04e4c5..ea374ae 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -819,6 +819,10 @@ msgstr "Autenticar con el proveedor LLM" msgid "Toggle debug logging" msgstr "Activar o desactivar el registro de depuración" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "Editar archivos de memoria de IAC-CODE" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "Ver y administrar memorias persistentes" @@ -827,6 +831,10 @@ msgstr "Ver y administrar memorias persistentes" msgid "[|search |delete |help]" msgstr "[|search |delete |help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "Exportar instantánea del prompt actual" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "Reanudar una sesión anterior" @@ -839,6 +847,10 @@ msgstr "[id de conversación o término de búsqueda]" msgid "Rename the current session" msgstr "Renombrar la sesión actual" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "Gestionar habilidades" @@ -1223,14 +1235,14 @@ msgid "Exit" msgstr "Salir" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "Uso: /memory [|search |delete |help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "Uso: /memory-folder [|search |delete |help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "Memorias guardadas:" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "Todavía no hay memorias guardadas." @@ -1242,7 +1254,7 @@ msgstr "Memorias coincidentes:" msgid "No matching memories." msgstr "No hay memorias coincidentes." -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "No se encontró la memoria '{name}'." @@ -1252,6 +1264,41 @@ msgstr "No se encontró la memoria '{name}'." msgid "Memory '{name}' deleted." msgstr "Memoria '{name}' eliminada." +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "El runtime de memoria no está disponible." + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "Memoria del proyecto" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "memoria del proyecto" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "Memoria de usuario" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "memoria de usuario" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "Error al abrir la memoria: {error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "Sin cambios en {scope}: {path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "{scope} guardada: {path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1276,6 +1323,286 @@ msgstr "Modelo actual: {model}" msgid "Kept model as {model}" msgstr "Se mantiene el modelo como {model}" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "El comando prompt requiere un contexto REPL." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "Error al exportar el prompt: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"Prompt exportado: {path}\n" +"No se pudo abrir automáticamente: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "Prompt exportado y abierto: {path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "La exportación del prompt solo está disponible en modo interactivo." + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "Última solicitud al modelo principal" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "Estado actual en ejecución" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "Generado" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "Sesión" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "Proveedor" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "Modelo" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "Directorio actual" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "Fuente" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "Prompt de sistema completo sin procesar" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "El prompt de sistema está vacío." + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "Aún no hay mensajes del proveedor." + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "No hay herramientas registradas actualmente." + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "Instantánea del prompt de IAC-CODE" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "Instantánea del prompt" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "" +"Vista local de diagnóstico del estado actual del prompt del modelo " +"principal. Esta exportación no activa la recuperación de memoria." + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "Secciones de la instantánea del prompt" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "TODO" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "Prompt de sistema" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "Mensajes del proveedor" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "Herramientas" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "Memoria de instrucciones" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "Índice de memoria del proyecto" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "Mecánica de memoria" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<{count} caracteres omitidos>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "Preámbulo" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "Prompt dinámico" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "Sección" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "" +"Presente en Mensajes del proveedor como una conversación oculta ." + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "No está presente en esta instantánea." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "Fuente: {source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. Prompt de sistema" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " Campo del proveedor: system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " Detalles: pestaña Prompt de sistema" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " Secciones: {count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. Mensajes del proveedor" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " Campo del proveedor: messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " Detalles: pestaña Mensajes del proveedor" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " Mensajes: {count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " Memoria recuperada: {status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. Herramientas" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " Campo del proveedor: tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " Detalles: pestaña Herramientas" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " Herramientas: {count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "" +"Parámetro system del proveedor. Se envía antes de los mensajes del " +"proveedor." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} secciones" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "" +"Mensajes de conversación en el orden de envío. La memoria recuperada " +"oculta aparece aquí." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} mensajes; memoria recuperada {status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "presente" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "no presente" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "" +"Definiciones de herramientas disponibles para el modelo principal en esta" +" solicitud." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} herramientas" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "Orden de ensamblaje del prompt" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "Abrir {tab_label}" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "mensaje" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "memoria recuperada" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "Esquema de entrada" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "herramienta" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "El cambio de nombre solo está disponible en modo interactivo." @@ -1332,26 +1659,10 @@ msgstr "El comando status requiere un contexto REPL." msgid "Status is only available in interactive mode." msgstr "status solo está disponible en modo interactivo." -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "Sesión" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "Proveedor" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "no configurado" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "Modelo" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "Directorio actual" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "Uso de tokens de API (registrado):" @@ -1388,6 +1699,66 @@ msgstr "Contexto" msgid "Session Status" msgstr "Estado de la sesión" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "Recuperacion de memoria" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Consultas laterales" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "" +"{total} en total, {success} correctas, {failed} fallidas, {cancelled} " +"canceladas" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "Ultimo intento" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status} en {duration} ms, {count} archivos seleccionados" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "Ultima llamada lateral" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "Archivos recientes" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "Uso de llamada lateral" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "Ultimo uso" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "No se informó uso de tokens" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "" +"{events} registros, entrada {input}, salida {output}, lectura de cache " +"{cache_read}, total {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "" +"entrada {input}, salida {output}, lectura de cache {cache_read}, total " +"{total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1437,6 +1808,40 @@ msgstr "(dinámico)" msgid "Aborted!" msgstr "¡Operación cancelada!" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "" +"Lee memorias persistentes. Omite name para listarlas todas o proporciona " +"name para leer una memoria específica." + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "Nombre de la memoria que se va a leer. Omítelo para listar todas." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "" +"Guarda una memoria persistente. Úsalo cuando el usuario pida " +"explícitamente recordar o conservar información. Elige un nombre conciso " +"y estable, un tipo adecuado, una descripción breve y el contenido útil " +"que se debe conservar. Tipos: {types}." + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "La memoria automática está desactivada." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "Memoria '{name}' guardada." + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2733,6 +3138,49 @@ msgstr "No hay historial de conversación" msgid "Message Preview" msgstr "Vista previa del mensaje" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "Memoria" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "Memoria automática: {state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "activada" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "desactivada" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Enter para confirmar · Esc para cancelar" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "Guardado en {path}" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "Abrir carpeta de memoria automatica" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "INSERTAR" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "NORMAL" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq guardar · :q! descartar" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "Abrir archivo" @@ -2875,14 +3323,6 @@ msgstr "No se encontraron habilidades" msgid "Bundled skills cannot be disabled." msgstr "Las habilidades integradas no se pueden deshabilitar." -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "activada" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "desactivada" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "bloqueada" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 7e92f1b..71c466c 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -818,6 +818,10 @@ msgstr "S’authentifier auprès du fournisseur LLM" msgid "Toggle debug logging" msgstr "Activer ou désactiver la journalisation debug" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "Modifier les fichiers memoire IAC-CODE" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "Afficher et gérer les mémoires persistantes" @@ -826,6 +830,10 @@ msgstr "Afficher et gérer les mémoires persistantes" msgid "[|search |delete |help]" msgstr "[|search |delete |help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "Exporter l’instantané du prompt actuel" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "Reprendre une session précédente" @@ -838,6 +846,10 @@ msgstr "[identifiant de conversation ou terme de recherche]" msgid "Rename the current session" msgstr "Renommer la session actuelle" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "Gérer les compétences" @@ -1228,14 +1240,14 @@ msgid "Exit" msgstr "Quitter" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "Utilisation : /memory [|search |delete |help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "Utilisation : /memory-folder [|search |delete |help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "Mémoires enregistrées :" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "Aucune mémoire enregistrée pour le moment." @@ -1247,7 +1259,7 @@ msgstr "Mémoires correspondantes :" msgid "No matching memories." msgstr "Aucune mémoire correspondante." -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "Mémoire '{name}' introuvable." @@ -1257,6 +1269,41 @@ msgstr "Mémoire '{name}' introuvable." msgid "Memory '{name}' deleted." msgstr "Mémoire '{name}' supprimée." +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "Le runtime de mémoire n’est pas disponible." + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "Mémoire du projet" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "mémoire du projet" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "Mémoire utilisateur" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "mémoire utilisateur" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "Échec de l’ouverture de la mémoire : {error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "Aucun changement pour {scope} : {path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "{scope} enregistrée : {path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1281,6 +1328,286 @@ msgstr "Modèle actuel : {model}" msgid "Kept model as {model}" msgstr "Modèle conservé : {model}" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "La commande prompt nécessite un contexte REPL." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "Échec de l’export du prompt : {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"Prompt exporté : {path}\n" +"Impossible de l’ouvrir automatiquement : {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "Prompt exporté et ouvert : {path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "L’export du prompt est disponible uniquement en mode interactif." + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "Dernière requête au modèle principal" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "État d'exécution actuel" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "Généré" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "Session" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "Fournisseur" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "Modèle" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "Répertoire courant" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "Source" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "Prompt système complet brut" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "Le prompt système est vide." + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "Aucun message fournisseur pour le moment." + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "Aucun outil n’est actuellement enregistré." + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "Instantané du prompt IAC-CODE" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "Instantané du prompt" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "" +"Vue de diagnostic locale de l’état actuel du prompt du modèle principal. " +"Cet export ne déclenche pas de rappel de mémoire." + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "Sections de l’instantané du prompt" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "TOUT" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "Prompt système" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "Messages fournisseur" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "Outils" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "Mémoire d’instructions" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "Index de mémoire du projet" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "Mécanique de mémoire" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<{count} caractères omis>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "Préambule" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "Prompt dynamique" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "Section" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "" +"Présent dans Messages fournisseur comme conversation masquée ." + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "Absent de cet instantané." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "Source : {source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. Prompt système" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " Champ fournisseur : system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " Détails : onglet Prompt système" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " Sections : {count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. Messages fournisseur" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " Champ fournisseur : messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " Détails : onglet Messages fournisseur" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " Messages : {count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " Mémoire rappelée : {status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. Outils" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " Champ fournisseur : tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " Détails : onglet Outils" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " Outils : {count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "" +"Paramètre system du fournisseur. Il est envoyé avant les messages " +"fournisseur." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} sections" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "" +"Messages de conversation dans l’ordre d’envoi. La mémoire rappelée " +"masquée apparaît ici." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} messages ; mémoire rappelée {status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "présente" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "absente" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "" +"Définitions d’outils disponibles pour le modèle principal dans cette " +"requête." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} outils" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "Ordre d’assemblage du prompt" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "Ouvrir {tab_label}" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "message" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "mémoire rappelée" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "Schéma d’entrée" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "outil" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "Le renommage n'est disponible qu'en mode interactif." @@ -1335,26 +1662,10 @@ msgstr "La commande status nécessite un contexte REPL." msgid "Status is only available in interactive mode." msgstr "status n’est disponible qu’en mode interactif." -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "Session" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "Fournisseur" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "non configuré" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "Modèle" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "Répertoire courant" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "Utilisation des tokens API (enregistrée) :" @@ -1391,6 +1702,66 @@ msgstr "Contexte" msgid "Session Status" msgstr "État de la session" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "Rappel memoire" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Requetes laterales" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "" +"{total} au total, {success} reussies, {failed} echouees, {cancelled} " +"annulees" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "Derniere tentative" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status} en {duration} ms, {count} fichiers selectionnes" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "Dernier appel lateral" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "Derniers fichiers" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "Utilisation de l'appel lateral" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "Derniere utilisation" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "Aucune utilisation de tokens signalée" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "" +"{events} enregistrements, entree {input}, sortie {output}, lecture du " +"cache {cache_read}, total {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "" +"entree {input}, sortie {output}, lecture du cache {cache_read}, total " +"{total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1440,6 +1811,40 @@ msgstr "(dynamique)" msgid "Aborted!" msgstr "Interrompu !" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "" +"Lit les mémoires persistantes. Omettez name pour tout lister ou " +"fournissez name pour lire une mémoire précise." + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "Nom de la mémoire à lire. Omettez-le pour tout lister." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "" +"Enregistre une mémoire persistante. À utiliser lorsque l’utilisateur " +"demande explicitement de mémoriser ou conserver une information. " +"Choisissez un nom concis et stable, un type approprié, une brève " +"description et le contenu utile à conserver. Types : {types}." + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "La mémoire automatique est désactivée." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "Mémoire '{name}' enregistrée." + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2738,6 +3143,49 @@ msgstr "Aucun historique de conversation" msgid "Message Preview" msgstr "Aperçu du message" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "Mémoire" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "Mémoire automatique : {state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "activée" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "désactivée" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Entrée pour confirmer · Échap pour annuler" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "Enregistré dans {path}" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "Ouvrir le dossier de memoire automatique" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "INSERTION" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "NORMAL" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq enregistrer · :q! abandonner" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "Ouvrir un fichier" @@ -2880,14 +3328,6 @@ msgstr "Aucune compétence trouvée" msgid "Bundled skills cannot be disabled." msgstr "Les compétences intégrées ne peuvent pas être désactivées." -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "activée" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "désactivée" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "verrouillée" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index aa536ba..63151be 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -785,6 +785,10 @@ msgstr "LLM プロバイダーで認証を行います" msgid "Toggle debug logging" msgstr "デバッグログのオン/オフを切り替えます" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "IAC-CODEメモリファイルを編集" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "永続メモリを表示および管理" @@ -793,6 +797,10 @@ msgstr "永続メモリを表示および管理" msgid "[|search |delete |help]" msgstr "[<名前>|search <検索語>|delete <名前>|help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "現在のプロンプトスナップショットをエクスポート" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "以前のセッションを再開します" @@ -805,6 +813,10 @@ msgstr "[会話 ID または検索語]" msgid "Rename the current session" msgstr "現在のセッション名を変更" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "<名前>" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "スキルを管理" @@ -1191,14 +1203,14 @@ msgid "Exit" msgstr "終了" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "使用法: /memory [<名前>|search <検索語>|delete <名前>|help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "使い方: /memory-folder [<名前>|search <検索語>|delete <名前>|help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "保存済みメモリ:" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "保存済みメモリはまだありません。" @@ -1210,7 +1222,7 @@ msgstr "一致するメモリ:" msgid "No matching memories." msgstr "一致するメモリはありません。" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "メモリ '{name}' が見つかりません。" @@ -1220,6 +1232,41 @@ msgstr "メモリ '{name}' が見つかりません。" msgid "Memory '{name}' deleted." msgstr "メモリ '{name}' を削除しました。" +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "メモリランタイムを利用できません。" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "プロジェクトメモリ" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "プロジェクトメモリ" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "ユーザーメモリ" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "ユーザーメモリ" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "メモリを開けませんでした: {error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "{scope} に変更はありません: {path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "{scope} を保存しました: {path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1244,6 +1291,276 @@ msgstr "現在のモデル:{model}" msgid "Kept model as {model}" msgstr "モデルを {model} のままにしました" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "prompt コマンドには REPL コンテキストが必要です。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "prompt のエクスポートに失敗しました: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"プロンプトをエクスポートしました: {path}\n" +"自動的に開けませんでした: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "プロンプトをエクスポートして開きました: {path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "prompt のエクスポートは対話モードでのみ利用できます。" + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "直近のメインモデルリクエスト" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "現在の実行時状態" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "生成日時" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "セッション" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "プロバイダー" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "モデル" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "現在のディレクトリ" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "ソース" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "完全な生のシステムプロンプト" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "システムプロンプトは空です。" + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "プロバイダーメッセージはまだありません。" + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "現在登録されているツールはありません。" + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "IAC-CODE プロンプトスナップショット" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "プロンプトスナップショット" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "現在のメインモデルプロンプト状態を確認するためのローカル診断ビューです。このエクスポートはメモリ呼び出しを実行しません。" + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "プロンプトスナップショットのセクション" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "すべて" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "システムプロンプト" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "プロバイダーメッセージ" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "ツール" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "指示メモリ" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "プロジェクトメモリ索引" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "メモリの仕組み" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<{count} 文字を省略>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "前文" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "動的プロンプト" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "セクション" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "非表示の会話 としてプロバイダーメッセージに存在します。" + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "このスナップショットには存在しません。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "ソース: {source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. システムプロンプト" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " プロバイダーフィールド: system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " 詳細: システムプロンプトタブ" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " セクション: {count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. プロバイダーメッセージ" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " プロバイダーフィールド: messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " 詳細: プロバイダーメッセージタブ" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " メッセージ: {count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " 呼び出されたメモリ: {status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. ツール" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " プロバイダーフィールド: tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " 詳細: ツールタブ" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " ツール: {count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "プロバイダーの system パラメーターです。プロバイダーメッセージより前に送信されます。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} セクション" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "送信順の会話メッセージです。非表示の会話内で呼び出されたメモリはここに表示されます。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} 件のメッセージ; 呼び出されたメモリは{status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "存在します" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "存在しません" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "このリクエストでメインモデルが利用できるツール定義です。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} 個のツール" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "プロンプトの組み立て順序" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "{tab_label} を開く" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "メッセージ" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "呼び出されたメモリ" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "入力スキーマ" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "ツール" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "名前の変更は対話モードでのみ使用できます。" @@ -1298,26 +1615,10 @@ msgstr "status コマンドには REPL コンテキストが必要です。" msgid "Status is only available in interactive mode." msgstr "status は対話モードでのみ利用できます。" -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "セッション" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "プロバイダー" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "未設定" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "モデル" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "現在のディレクトリ" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "API トークン使用量(記録済み):" @@ -1354,6 +1655,60 @@ msgstr "コンテキスト" msgid "Session Status" msgstr "セッション状態" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "メモリ呼び出し" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "サイドクエリ" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "合計 {total}、成功 {success}、失敗 {failed}、キャンセル {cancelled}" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "直近の試行" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status}、{duration} ms、{count} 個のファイルを選択" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "直近のサイド呼び出し" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "最近のファイル" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "サイド呼び出しの使用量" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "直近の使用量" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "token 使用量は報告されていません" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "{events} 件の記録、入力 {input}、出力 {output}、キャッシュ読み取り {cache_read}、合計 {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "入力 {input}、出力 {output}、キャッシュ読み取り {cache_read}、合計 {total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1403,6 +1758,36 @@ msgstr "(動的)" msgid "Aborted!" msgstr "中断しました。" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "永続メモリを読み取ります。すべてを一覧表示するには name を省略し、特定のメモリを読むには name を指定します。" + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "読み取るメモリ名。すべてを一覧表示するには省略します。" + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "" +"永続メモリを保存します。ユーザーが情報を記憶または保持するよう明示的に依頼した場合に使用します。簡潔で安定した名前、適切な種類、短い説明、保持する有用な内容を選んでください。種類:" +" {types}。" + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "自動メモリはオフです。" + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "メモリ '{name}' を保存しました。" + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2656,6 +3041,49 @@ msgstr "会話履歴がありません" msgid "Message Preview" msgstr "メッセージのプレビュー" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "メモリ" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "自動メモリ: {state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "有効" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "無効" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Enter で確定 · Esc でキャンセル" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "{path} に保存済み" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "自動メモリフォルダを開く" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "挿入" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "通常" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq 保存 · :q! 破棄" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "ファイルを開く" @@ -2791,14 +3219,6 @@ msgstr "スキルが見つかりません" msgid "Bundled skills cannot be disabled." msgstr "バンドルスキルは無効にできません。" -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "有効" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "無効" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "ロック済み" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index af19425..4ba9a0e 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -813,6 +813,10 @@ msgstr "Autenticar com o provedor LLM" msgid "Toggle debug logging" msgstr "Alternar debug" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "Editar arquivos de memoria do IAC-CODE" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "Ver e gerenciar memórias persistentes" @@ -821,6 +825,10 @@ msgstr "Ver e gerenciar memórias persistentes" msgid "[|search |delete |help]" msgstr "[|search |delete |help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "Exportar instantâneo do prompt atual" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "Retomar uma sessão anterior" @@ -833,6 +841,10 @@ msgstr "[ID da conversa ou termo de busca]" msgid "Rename the current session" msgstr "Renomear a sessão atual" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "Gerenciar habilidades" @@ -1217,14 +1229,14 @@ msgid "Exit" msgstr "Sair" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "Uso: /memory [|search |delete |help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "Uso: /memory-folder [|search |delete |help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "Memórias salvas:" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "Ainda não há memórias salvas." @@ -1236,7 +1248,7 @@ msgstr "Memórias correspondentes:" msgid "No matching memories." msgstr "Nenhuma memória correspondente." -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "Memória '{name}' não encontrada." @@ -1246,6 +1258,41 @@ msgstr "Memória '{name}' não encontrada." msgid "Memory '{name}' deleted." msgstr "Memória '{name}' excluída." +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "O runtime de memória não está disponível." + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "Memória do projeto" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "memória do projeto" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "Memória do usuário" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "memória do usuário" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "Falha ao abrir a memória: {error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "Nenhuma alteração em {scope}: {path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "{scope} salva: {path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1270,6 +1317,286 @@ msgstr "Modelo atual: {model}" msgid "Kept model as {model}" msgstr "Modelo mantido como {model}" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "O comando prompt requer um contexto REPL." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "Falha ao exportar o prompt: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"Prompt exportado: {path}\n" +"Falha ao abri-lo automaticamente: {error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "Prompt exportado e aberto: {path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "A exportação do prompt só está disponível no modo interativo." + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "Última solicitação ao modelo principal" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "Estado atual em execução" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "Gerado" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "Sessão" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "Provedor" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "Modelo" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "Diretório atual" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "Fonte" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "Prompt de sistema completo bruto" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "O prompt de sistema está vazio." + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "Ainda não há mensagens do provedor." + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "Nenhuma ferramenta está registrada no momento." + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "Instantâneo do prompt do IAC-CODE" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "Instantâneo do prompt" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "" +"Visualização local de diagnóstico do estado atual do prompt do modelo " +"principal. Esta exportação não aciona recuperação de memória." + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "Seções do instantâneo do prompt" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "TUDO" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "Prompt de sistema" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "Mensagens do provedor" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "Ferramentas" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "Memória de instruções" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "Índice de memória do projeto" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "Mecânica da memória" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<{count} caracteres omitidos>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "Preâmbulo" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "Prompt dinâmico" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "Seção" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "" +"Presente em Mensagens do provedor como uma conversa oculta ." + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "Não presente neste instantâneo." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "Fonte: {source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. Prompt de sistema" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " Campo do provedor: system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " Detalhes: aba Prompt de sistema" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " Seções: {count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. Mensagens do provedor" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " Campo do provedor: messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " Detalhes: aba Mensagens do provedor" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " Mensagens: {count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " Memória recuperada: {status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. Ferramentas" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " Campo do provedor: tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " Detalhes: aba Ferramentas" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " Ferramentas: {count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "" +"Parâmetro system do provedor. Ele é enviado antes das mensagens do " +"provedor." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} seções" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "" +"Mensagens da conversa na ordem de envio. A memória recuperada oculta " +"aparece aqui." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} mensagens; memória recuperada {status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "presente" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "não presente" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "" +"Definições de ferramentas disponíveis ao modelo principal para esta " +"solicitação." + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} ferramentas" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "Ordem de montagem do prompt" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "Abrir {tab_label}" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "mensagem" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "memória recuperada" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "Esquema de entrada" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "ferramenta" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "Renomear só está disponível no modo interativo." @@ -1324,26 +1651,10 @@ msgstr "O comando status requer um contexto REPL." msgid "Status is only available in interactive mode." msgstr "status está disponível apenas no modo interativo." -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "Sessão" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "Provedor" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "não configurado" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "Modelo" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "Diretório atual" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "Uso de tokens da API (registrado):" @@ -1380,6 +1691,66 @@ msgstr "Contexto" msgid "Session Status" msgstr "Status da sessão" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "Recuperacao de memoria" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Consultas laterais" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "" +"{total} no total, {success} com sucesso, {failed} com falha, {cancelled} " +"canceladas" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "Ultima tentativa" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status} em {duration} ms, {count} arquivos selecionados" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "Ultima chamada lateral" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "Arquivos recentes" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "Uso da chamada lateral" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "Ultimo uso" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "Nenhum uso de tokens relatado" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "" +"{events} registros, entrada {input}, saida {output}, leitura de cache " +"{cache_read}, total {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "" +"entrada {input}, saida {output}, leitura de cache {cache_read}, total " +"{total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1429,6 +1800,40 @@ msgstr "(dinâmico)" msgid "Aborted!" msgstr "Abortado!" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "" +"Lê memórias persistentes. Omita name para listar todas ou forneça name " +"para ler uma memória específica." + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "Nome da memória a ler. Omita para listar todas." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "" +"Salva uma memória persistente. Use quando o usuário pedir explicitamente " +"para lembrar ou preservar informações. Escolha um nome conciso e estável," +" um tipo apropriado, uma breve descrição e o conteúdo útil a manter. " +"Tipos: {types}." + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "A memória automática está desativada." + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "Memória '{name}' salva." + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2711,6 +3116,49 @@ msgstr "Sem histórico de conversa" msgid "Message Preview" msgstr "Pré-visualização da mensagem" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "Memória" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "Memória automática: {state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "ativada" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "desativada" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Enter para confirmar · Esc para cancelar" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "Salvo em {path}" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "Abrir pasta de memoria automatica" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "INSERIR" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "NORMAL" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq salvar · :q! descartar" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "Abrir arquivo" @@ -2853,14 +3301,6 @@ msgstr "Nenhuma habilidade encontrada" msgid "Bundled skills cannot be disabled." msgstr "Habilidades integradas não podem ser desativadas." -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "ativada" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "desativada" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "bloqueada" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index f72b4ac..820874a 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -777,6 +777,10 @@ msgstr "配置 LLM 提供商认证" msgid "Toggle debug logging" msgstr "切换调试日志开关" +#: src/iac_code/commands/__init__.py +msgid "Edit IAC-CODE memory files" +msgstr "编辑 IAC-CODE 记忆文件" + #: src/iac_code/commands/__init__.py msgid "View and manage persistent memories" msgstr "查看和管理持久记忆" @@ -785,6 +789,10 @@ msgstr "查看和管理持久记忆" msgid "[|search |delete |help]" msgstr "[<名称>|search <查询>|delete <名称>|help]" +#: src/iac_code/commands/__init__.py +msgid "Export current prompt snapshot" +msgstr "导出当前 prompt 快照" + #: src/iac_code/commands/__init__.py msgid "Resume a previous session" msgstr "恢复之前的会话" @@ -797,6 +805,10 @@ msgstr "[会话 ID 或搜索词]" msgid "Rename the current session" msgstr "重命名当前会话" +#: src/iac_code/commands/__init__.py +msgid "" +msgstr "<名称>" + #: src/iac_code/commands/__init__.py msgid "Manage skills" msgstr "管理技能" @@ -1181,14 +1193,14 @@ msgid "Exit" msgstr "退出" #: src/iac_code/commands/memory.py -msgid "Usage: /memory [|search |delete |help]" -msgstr "用法:/memory [<名称>|search <查询>|delete <名称>|help]" +msgid "Usage: /memory-folder [|search |delete |help]" +msgstr "用法:/memory-folder [<名称>|search <查询>|delete <名称>|help]" #: src/iac_code/commands/memory.py msgid "Saved memories:" msgstr "已保存的记忆:" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py msgid "No memories saved yet." msgstr "尚未保存任何记忆。" @@ -1200,7 +1212,7 @@ msgstr "匹配的记忆:" msgid "No matching memories." msgstr "没有匹配的记忆。" -#: src/iac_code/commands/memory.py +#: src/iac_code/commands/memory.py src/iac_code/memory/memory_tools.py #, python-brace-format msgid "Memory '{name}' not found." msgstr "未找到记忆 '{name}'。" @@ -1210,6 +1222,41 @@ msgstr "未找到记忆 '{name}'。" msgid "Memory '{name}' deleted." msgstr "记忆 '{name}' 已删除。" +#: src/iac_code/commands/memory.py +msgid "Memory runtime is unavailable." +msgstr "记忆运行时不可用。" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "Project memory" +msgstr "项目记忆" + +#: src/iac_code/commands/memory.py +msgid "project memory" +msgstr "项目记忆" + +#: src/iac_code/commands/memory.py src/iac_code/ui/dialogs/memory.py +msgid "User memory" +msgstr "用户记忆" + +#: src/iac_code/commands/memory.py +msgid "user memory" +msgstr "用户记忆" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Failed to open memory: {error}" +msgstr "打开记忆失败:{error}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "No changes made to {scope}: {path}" +msgstr "{scope}没有变更:{path}" + +#: src/iac_code/commands/memory.py +#, python-brace-format +msgid "Saved {scope}: {path}" +msgstr "已保存{scope}:{path}" + #: src/iac_code/commands/model.py #, python-brace-format msgid "" @@ -1232,6 +1279,276 @@ msgstr "当前模型:{model}" msgid "Kept model as {model}" msgstr "保持模型为 {model}" +#: src/iac_code/commands/prompt.py +msgid "Prompt command requires a REPL context." +msgstr "prompt 命令需要 REPL 上下文。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Failed to export prompt: {error}" +msgstr "导出 prompt 失败:{error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +"Prompt exported: {path}\n" +"Failed to open it automatically: {error}" +msgstr "" +"prompt 已导出:{path}\n" +"无法自动打开:{error}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Prompt exported and opened: {path}" +msgstr "prompt 已导出并打开:{path}" + +#: src/iac_code/commands/prompt.py +msgid "Prompt export is only available in interactive mode." +msgstr "prompt 导出仅在交互模式下可用。" + +#: src/iac_code/commands/prompt.py +msgid "Last main-model request" +msgstr "最近一次主模型请求" + +#: src/iac_code/commands/prompt.py +msgid "Current runtime state" +msgstr "当前运行时状态" + +#: src/iac_code/commands/prompt.py +msgid "Generated" +msgstr "生成时间" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +#: src/iac_code/ui/banner.py +msgid "Session" +msgstr "会话" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Provider" +msgstr "提供商" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "Model" +msgstr "模型" + +#: src/iac_code/commands/prompt.py src/iac_code/commands/status.py +msgid "CWD" +msgstr "当前目录" + +#: src/iac_code/commands/prompt.py +msgid "Source" +msgstr "来源" + +#: src/iac_code/commands/prompt.py +msgid "Raw Full System Prompt" +msgstr "完整原始系统提示词" + +#: src/iac_code/commands/prompt.py +msgid "System prompt is empty." +msgstr "系统提示词为空。" + +#: src/iac_code/commands/prompt.py +msgid "No provider messages yet." +msgstr "暂无提供商消息。" + +#: src/iac_code/commands/prompt.py +msgid "No tools are currently registered." +msgstr "当前未注册任何工具。" + +#: src/iac_code/commands/prompt.py +msgid "IAC-CODE Prompt Snapshot" +msgstr "IAC-CODE Prompt 快照" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Snapshot" +msgstr "Prompt 快照" + +#: src/iac_code/commands/prompt.py +msgid "" +"A local diagnostic view of the current main-model prompt state. This " +"export does not trigger memory recall." +msgstr "当前主模型 prompt 状态的本地诊断视图。此导出不会触发记忆召回。" + +#: src/iac_code/commands/prompt.py +msgid "Prompt snapshot sections" +msgstr "Prompt 快照分区" + +#: src/iac_code/commands/prompt.py +msgid "ALL" +msgstr "全部" + +#: src/iac_code/commands/prompt.py +msgid "System Prompt" +msgstr "系统提示词" + +#: src/iac_code/commands/prompt.py +msgid "Provider Messages" +msgstr "提供商消息" + +#: src/iac_code/commands/prompt.py +msgid "Tools" +msgstr "工具" + +#: src/iac_code/commands/prompt.py +msgid "Instruction Memory" +msgstr "指令记忆" + +#: src/iac_code/commands/prompt.py +msgid "Project Memory Index" +msgstr "项目记忆索引" + +#: src/iac_code/commands/prompt.py +msgid "Memory Mechanics" +msgstr "记忆机制" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "" +msgstr "<已省略 {count} 个字符>" + +#: src/iac_code/commands/prompt.py +msgid "Preamble" +msgstr "前言" + +#: src/iac_code/commands/prompt.py +msgid "Dynamic Prompt" +msgstr "动态提示词" + +#: src/iac_code/commands/prompt.py +msgid "Section" +msgstr "分区" + +#: src/iac_code/commands/prompt.py +msgid "Present in Provider Messages as a hidden conversation ." +msgstr "作为隐藏对话 存在于提供商消息中。" + +#: src/iac_code/commands/prompt.py +msgid "Not present in this snapshot." +msgstr "此快照中不存在。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Source: {source}" +msgstr "来源:{source}" + +#: src/iac_code/commands/prompt.py +msgid "1. System Prompt" +msgstr "1. 系统提示词" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: system" +msgstr " 提供商字段:system" + +#: src/iac_code/commands/prompt.py +msgid " Details: System Prompt tab" +msgstr " 详情:系统提示词标签页" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Sections: {count}" +msgstr " 分区:{count}" + +#: src/iac_code/commands/prompt.py +msgid "2. Provider Messages" +msgstr "2. 提供商消息" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: messages" +msgstr " 提供商字段:messages" + +#: src/iac_code/commands/prompt.py +msgid " Details: Provider Messages tab" +msgstr " 详情:提供商消息标签页" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Messages: {count}" +msgstr " 消息:{count}" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Recalled memory: {status}" +msgstr " 已召回记忆:{status}" + +#: src/iac_code/commands/prompt.py +msgid "3. Tools" +msgstr "3. 工具" + +#: src/iac_code/commands/prompt.py +msgid " Provider field: tools" +msgstr " 提供商字段:tools" + +#: src/iac_code/commands/prompt.py +msgid " Details: Tools tab" +msgstr " 详情:工具标签页" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid " Tools: {count}" +msgstr " 工具:{count}" + +#: src/iac_code/commands/prompt.py +msgid "Provider system parameter. This is sent before provider messages." +msgstr "提供商 system 参数。它会在提供商消息之前发送。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} sections" +msgstr "{count} 个分区" + +#: src/iac_code/commands/prompt.py +msgid "" +"Conversation messages in send order. Hidden conversation recalled memory " +"appears here." +msgstr "按发送顺序排列的对话消息。隐藏的对话召回记忆会显示在这里。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} messages; recalled memory {status}" +msgstr "{count} 条消息;召回记忆{status}" + +#: src/iac_code/commands/prompt.py +msgid "present" +msgstr "存在" + +#: src/iac_code/commands/prompt.py +msgid "not present" +msgstr "不存在" + +#: src/iac_code/commands/prompt.py +msgid "Tool definitions available to the main model for this request." +msgstr "本次请求中主模型可用的工具定义。" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "{count} tools" +msgstr "{count} 个工具" + +#: src/iac_code/commands/prompt.py +msgid "Prompt Assembly Order" +msgstr "Prompt 组装顺序" + +#: src/iac_code/commands/prompt.py +#, python-brace-format +msgid "Open {tab_label}" +msgstr "打开{tab_label}" + +#: src/iac_code/commands/prompt.py +msgid "message" +msgstr "消息" + +#: src/iac_code/commands/prompt.py +msgid "recalled memory" +msgstr "已召回记忆" + +#: src/iac_code/commands/prompt.py +msgid "Input schema" +msgstr "输入 schema" + +#: src/iac_code/commands/prompt.py +msgid "tool" +msgstr "工具" + #: src/iac_code/commands/rename.py msgid "Rename is only available in interactive mode." msgstr "重命名仅在交互模式下可用。" @@ -1286,26 +1603,10 @@ msgstr "status 命令需要 REPL 上下文。" msgid "Status is only available in interactive mode." msgstr "status 仅在交互模式下可用。" -#: src/iac_code/commands/status.py src/iac_code/ui/banner.py -msgid "Session" -msgstr "会话" - -#: src/iac_code/commands/status.py -msgid "Provider" -msgstr "提供商" - #: src/iac_code/commands/status.py msgid "not configured" msgstr "未配置" -#: src/iac_code/commands/status.py -msgid "Model" -msgstr "模型" - -#: src/iac_code/commands/status.py -msgid "CWD" -msgstr "当前目录" - #: src/iac_code/commands/status.py msgid "API Token Usage (recorded):" msgstr "API Token 用量(已记录):" @@ -1342,6 +1643,60 @@ msgstr "上下文" msgid "Session Status" msgstr "会话状态" +#: src/iac_code/commands/status.py +msgid "Memory Recall" +msgstr "记忆召回" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "旁路查询" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" +msgstr "{total} 次总计,{success} 次成功,{failed} 次失败,{cancelled} 次取消" + +#: src/iac_code/commands/status.py +msgid "Last attempt" +msgstr "最近尝试" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{status} in {duration} ms, {count} files selected" +msgstr "{status},耗时 {duration} 毫秒,选择 {count} 个文件" + +#: src/iac_code/commands/status.py +msgid "Last side call" +msgstr "最近旁路" + +#: src/iac_code/commands/status.py +msgid "Last files" +msgstr "最近文件" + +#: src/iac_code/commands/status.py +msgid "Side call usage" +msgstr "旁路消耗" + +#: src/iac_code/commands/status.py +msgid "Last usage" +msgstr "最近消耗" + +#: src/iac_code/commands/status.py +msgid "No token usage reported" +msgstr "未报告 token 用量" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "" +"{events} records, input {input}, output {output}, cache read " +"{cache_read}, total {total}" +msgstr "{events} 次记录,输入 {input},输出 {output},缓存读取 {cache_read},总计 {total}" + +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "input {input}, output {output}, cache read {cache_read}, total {total}" +msgstr "输入 {input},输出 {output},缓存读取 {cache_read},总计 {total}" + #: src/iac_code/commands/status.py #, python-brace-format msgid "{session_id} (resumed)" @@ -1391,6 +1746,34 @@ msgstr "(动态)" msgid "Aborted!" msgstr "已中止!" +#: src/iac_code/memory/memory_tools.py +msgid "" +"Read persistent memories. Omit name to list all, or provide name to read " +"specific memory." +msgstr "读取持久记忆。省略 name 可列出全部,提供 name 可读取指定记忆。" + +#: src/iac_code/memory/memory_tools.py +msgid "Memory name to read. Omit to list all." +msgstr "要读取的记忆名称。省略则列出全部。" + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "" +"Save a persistent memory. Use when the user explicitly asks you to " +"remember or preserve information. Choose a concise, stable name, an " +"appropriate type, a short description, and the useful content to keep. " +"Types: {types}." +msgstr "保存一条持久记忆。当用户明确要求记住或保留信息时使用。选择简洁稳定的名称、合适的类型、简短描述以及需要保留的有用内容。类型:{types}。" + +#: src/iac_code/memory/memory_tools.py +msgid "Auto-memory is off." +msgstr "自动记忆已关闭。" + +#: src/iac_code/memory/memory_tools.py +#, python-brace-format +msgid "Memory '{name}' saved." +msgstr "记忆 '{name}' 已保存。" + #: src/iac_code/providers/manager.py #, python-brace-format msgid "Cannot determine provider for model: {model}. Run /auth to configure." @@ -2628,6 +3011,49 @@ msgstr "没有对话历史" msgid "Message Preview" msgstr "消息预览" +#: src/iac_code/ui/dialogs/memory.py +msgid "Memory" +msgstr "记忆" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Auto-memory: {state}" +msgstr "自动记忆:{state}" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "on" +msgstr "启用" + +#: src/iac_code/ui/dialogs/memory.py src/iac_code/ui/dialogs/skills_picker.py +msgid "off" +msgstr "禁用" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Enter to confirm · Esc to cancel" +msgstr "Enter 确认 · Esc 取消" + +#: src/iac_code/ui/dialogs/memory.py +#, python-brace-format +msgid "Saved in {path}" +msgstr "保存在 {path}" + +#: src/iac_code/ui/dialogs/memory.py +msgid "Open auto-memory folder" +msgstr "打开自动记忆文件夹" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "INSERT" +msgstr "插入" + +#: src/iac_code/ui/dialogs/memory_editor.py +msgid "NORMAL" +msgstr "普通" + +#: src/iac_code/ui/dialogs/memory_editor.py +#, python-brace-format +msgid "{status} :wq save · :q! discard" +msgstr "{status} :wq 保存 · :q! 放弃" + #: src/iac_code/ui/dialogs/quick_open.py msgid "Open File" msgstr "打开文件" @@ -2763,14 +3189,6 @@ msgstr "未找到技能" msgid "Bundled skills cannot be disabled." msgstr "内置技能不能被禁用。" -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "on" -msgstr "启用" - -#: src/iac_code/ui/dialogs/skills_picker.py -msgid "off" -msgstr "禁用" - #: src/iac_code/ui/dialogs/skills_picker.py msgid "locked" msgstr "已锁定" diff --git a/src/iac_code/memory/memory_manager.py b/src/iac_code/memory/memory_manager.py index e626e96..c2b984f 100644 --- a/src/iac_code/memory/memory_manager.py +++ b/src/iac_code/memory/memory_manager.py @@ -79,6 +79,14 @@ def list_memories(self) -> list[dict[str, Any]]: memories.append(mem) return memories + def list_memory_metadata(self) -> list[dict[str, Any]]: + memories = [] + for path in self._iter_memory_files(): + mem = self._load_memory_metadata(path) + if mem: + memories.append(mem) + return memories + def search(self, query: str) -> list[dict[str, Any]]: needle = query.strip().casefold() if not needle: @@ -164,6 +172,22 @@ def _load_memory_file(self, path: Path) -> dict[str, Any] | None: except OSError: return None + def _load_memory_metadata(self, path: Path) -> dict[str, Any] | None: + result: dict[str, Any] = {} + try: + with open(path, encoding="utf-8") as f: + if not f.readline().startswith("---"): + return None + for line in f: + if line.strip() == "---": + return result + if ":" in line: + key, value = line.split(":", 1) + result[key.strip()] = value.strip() + except OSError: + return None + return None + @staticmethod def _parse_memory_file(text: str) -> dict[str, Any]: result: dict[str, Any] = {} diff --git a/src/iac_code/memory/memory_tools.py b/src/iac_code/memory/memory_tools.py index 6a8eb6d..c634fda 100644 --- a/src/iac_code/memory/memory_tools.py +++ b/src/iac_code/memory/memory_tools.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any +from iac_code.i18n import _ from iac_code.memory.memory_manager import MEMORY_TYPES, MemoryManager +from iac_code.memory.project_memory import is_auto_memory_enabled from iac_code.tools.base import Tool, ToolContext, ToolResult @@ -18,7 +21,7 @@ def name(self) -> str: @property def description(self) -> str: - return "Read persistent memories. Omit name to list all, or provide name to read specific memory." + return _("Read persistent memories. Omit name to list all, or provide name to read specific memory.") @property def input_schema(self) -> dict[str, Any]: @@ -27,7 +30,7 @@ def input_schema(self) -> dict[str, Any]: "properties": { "name": { "type": "string", - "description": "Memory name to read. Omit to list all.", + "description": _("Memory name to read. Omit to list all."), } }, } @@ -37,19 +40,20 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> if name: mem = self._manager.load(name) if mem is None: - return ToolResult.error(f"Memory '{name}' not found.") + return ToolResult.error(_("Memory '{name}' not found.").format(name=name)) return ToolResult.success(f"[{mem.get('type', '')}] {mem.get('description', '')}\n\n{mem['content']}") else: index = self._manager.get_index_content() - return ToolResult.success(index or "No memories saved yet.") + return ToolResult.success(index or _("No memories saved yet.")) def is_read_only(self, input: dict | None = None) -> bool: return True class WriteMemoryTool(Tool): - def __init__(self, memory_manager: MemoryManager): + def __init__(self, memory_manager: MemoryManager, is_enabled: Callable[[], bool] | None = None): self._manager = memory_manager + self._is_enabled = is_enabled or is_auto_memory_enabled @property def name(self) -> str: @@ -58,11 +62,11 @@ def name(self) -> str: @property def description(self) -> str: types = ", ".join(sorted(MEMORY_TYPES)) - return ( + return _( "Save a persistent memory. Use when the user explicitly asks you to remember or preserve information. " "Choose a concise, stable name, an appropriate type, a short description, and the useful content to keep. " - f"Types: {types}." - ) + "Types: {types}." + ).format(types=types) @property def input_schema(self) -> dict[str, Any]: @@ -78,6 +82,8 @@ def input_schema(self) -> dict[str, Any]: } async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if not self._is_enabled(): + return ToolResult.error(_("Auto-memory is off.")) try: self._manager.save( name=tool_input["name"], @@ -85,7 +91,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> memory_type=tool_input["memory_type"], description=tool_input["description"], ) - return ToolResult.success(f"Memory '{tool_input['name']}' saved.") + return ToolResult.success(_("Memory '{name}' saved.").format(name=tool_input["name"])) except Exception as e: return ToolResult.error(str(e)) diff --git a/src/iac_code/memory/project_memory.py b/src/iac_code/memory/project_memory.py new file mode 100644 index 0000000..4555a45 --- /dev/null +++ b/src/iac_code/memory/project_memory.py @@ -0,0 +1,133 @@ +"""Project-scoped memory paths and prompt context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from iac_code.config import _load_yaml, _save_yaml, get_config_dir, get_settings_path +from iac_code.memory.memory_manager import MemoryManager +from iac_code.utils.file_security import ensure_private_dir +from iac_code.utils.project_paths import find_git_worktree_root, sanitize_path + +INSTRUCTION_MEMORY_FILE = "IAC-CODE.md" +_MEMORY_SETTINGS_KEY = "memory" +_AUTO_MEMORY_SETTINGS_KEY = "autoMemory" + + +@dataclass(frozen=True) +class MemoryContext: + instruction_memory_content: str = "" + memory_index_content: str = "" + memory_mechanics_content: str = "" + + def has_content(self) -> bool: + return any( + ( + self.instruction_memory_content.strip(), + self.memory_index_content.strip(), + self.memory_mechanics_content.strip(), + ) + ) + + +def resolve_project_root(cwd: str) -> Path: + git_root = find_git_worktree_root(cwd) + if git_root is not None: + return git_root + return Path(cwd).expanduser().resolve() + + +def project_key_for_cwd(cwd: str) -> str: + return sanitize_path(str(resolve_project_root(cwd))) + + +def get_project_memory_dir(cwd: str) -> Path: + return get_config_dir() / "projects" / project_key_for_cwd(cwd) / "memory" + + +class ProjectMemoryRuntime: + def __init__(self, cwd: str): + self.project_root = resolve_project_root(cwd) + self.user_instruction_path = get_config_dir() / INSTRUCTION_MEMORY_FILE + self.project_instruction_path = self.project_root / INSTRUCTION_MEMORY_FILE + self.auto_memory_dir = get_project_memory_dir(cwd) + self.memory_manager = MemoryManager(memory_dir=str(self.auto_memory_dir)) + + def ensure_instruction_file(self, scope: str) -> Path: + if scope == "user": + return self.user_instruction_path + elif scope == "project": + return self.project_instruction_path + else: + raise ValueError(f"Invalid memory scope: {scope}") + + def ensure_auto_memory_dir(self) -> Path: + return ensure_private_dir(self.auto_memory_dir) + + def build_memory_context(self) -> MemoryContext: + instruction_content = self._build_instruction_memory_content() + index_content = self.memory_manager.get_index_content().strip() + return MemoryContext( + instruction_memory_content=instruction_content, + memory_index_content=index_content, + memory_mechanics_content=_memory_mechanics_content(is_auto_memory_enabled()), + ) + + def _build_instruction_memory_content(self) -> str: + parts: list[str] = [] + for label, path in ( + ("User IAC-CODE.md", self.user_instruction_path), + ("Project IAC-CODE.md", self.project_instruction_path), + ): + content = _read_text_if_present(path) + if content: + parts.append(f"## {label}\n{content}") + return "\n\n".join(parts) + + +def _read_text_if_present(path: Path) -> str: + try: + if path.is_symlink() or not path.is_file(): + return "" + return path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def is_auto_memory_enabled() -> bool: + settings = _load_yaml(get_settings_path()) + memory_settings = settings.get(_MEMORY_SETTINGS_KEY) + if not isinstance(memory_settings, dict): + return True + enabled = memory_settings.get(_AUTO_MEMORY_SETTINGS_KEY) + return enabled if isinstance(enabled, bool) else True + + +def save_auto_memory_enabled(enabled: bool) -> None: + settings = _load_yaml(get_settings_path()) + memory_settings = settings.get(_MEMORY_SETTINGS_KEY) + if not isinstance(memory_settings, dict): + memory_settings = {} + memory_settings[_AUTO_MEMORY_SETTINGS_KEY] = bool(enabled) + settings[_MEMORY_SETTINGS_KEY] = memory_settings + _save_yaml(get_settings_path(), settings) + + +def _memory_mechanics_content(auto_memory_enabled: bool) -> str: + auto_memory_line = ( + "- Auto-memory is on; topic memories may be recalled and updated when the user asks." + if auto_memory_enabled + else "- Auto-memory is off; do not use write_memory and topic memories are not automatically recalled." + ) + return "\n".join( + [ + "Use memory carefully:", + "- IAC-CODE.md files contain always-on user and project instructions.", + "- MEMORY.md is an always-on index of project topic memories.", + auto_memory_line, + "- Topic files are not always injected; use read_memory to inspect relevant topics.", + "- Use write_memory only when the user explicitly asks to remember or preserve information.", + "- Treat recalled memories as potentially stale and verify before relying on time-sensitive facts.", + ] + ) diff --git a/src/iac_code/memory/recall.py b/src/iac_code/memory/recall.py new file mode 100644 index 0000000..5b79cfb --- /dev/null +++ b/src/iac_code/memory/recall.py @@ -0,0 +1,446 @@ +"""LLM-assisted project memory recall.""" + +from __future__ import annotations + +import asyncio +import json +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from iac_code.memory.memory_manager import MemoryManager +from iac_code.memory.project_memory import is_auto_memory_enabled +from iac_code.providers.base import Message +from iac_code.types.stream_events import Usage + + +@dataclass(frozen=True) +class MemoryRecallResult: + content: str = "" + selected_files: list[str] = field(default_factory=list) + status: str = "skipped" + usage: Usage | None = None + + +class MemoryRecallPrefetch: + """Non-blocking handle for a turn-scoped memory recall task.""" + + def __init__(self, task: asyncio.Task[MemoryRecallResult], *, on_cancel: Callable[[], None] | None = None) -> None: + self._task = task + self._on_cancel = on_cancel + self._cancel_recorded = False + + def done(self) -> bool: + return self._task.done() + + def add_done_callback(self, callback: Callable[[asyncio.Task[MemoryRecallResult]], None]) -> None: + self._task.add_done_callback(callback) + + def result(self) -> MemoryRecallResult: + return self._task.result() + + async def wait(self) -> MemoryRecallResult: + return await self._task + + def cancel(self) -> None: + if self._task.done(): + return + if not self._cancel_recorded and self._on_cancel is not None: + self._on_cancel() + self._cancel_recorded = True + self._task.cancel() + + +@dataclass +class MemoryRecallStats: + total_side_queries: int = 0 + successful_side_queries: int = 0 + failed_side_queries: int = 0 + cancelled_side_queries: int = 0 + total_selected_files: int = 0 + last_duration_ms: int = 0 + last_status: str = "skipped" + last_selected_files: list[str] = field(default_factory=list) + last_side_query_duration_ms: int = 0 + last_side_query_status: str = "skipped" + last_side_query_selected_files: list[str] = field(default_factory=list) + last_prompt_preview: str = "" + last_response_preview: str = "" + last_prompt_chars: int = 0 + last_response_chars: int = 0 + total_usage: MemoryRecallUsageStats = field(default_factory=lambda: MemoryRecallUsageStats()) + last_usage: MemoryRecallUsageStats = field(default_factory=lambda: MemoryRecallUsageStats()) + + def snapshot(self) -> dict[str, Any]: + return { + "total_side_queries": self.total_side_queries, + "successful_side_queries": self.successful_side_queries, + "failed_side_queries": self.failed_side_queries, + "cancelled_side_queries": self.cancelled_side_queries, + "total_selected_files": self.total_selected_files, + "last_duration_ms": self.last_duration_ms, + "last_status": self.last_status, + "last_selected_files": list(self.last_selected_files), + "last_side_query_duration_ms": self.last_side_query_duration_ms, + "last_side_query_status": self.last_side_query_status, + "last_side_query_selected_files": list(self.last_side_query_selected_files), + "last_prompt_preview": self.last_prompt_preview, + "last_response_preview": self.last_response_preview, + "last_prompt_chars": self.last_prompt_chars, + "last_response_chars": self.last_response_chars, + "total_usage": self.total_usage.snapshot(), + "last_usage": self.last_usage.snapshot(), + } + + def record_usage(self, usage: Usage) -> None: + if _usage_is_zero(usage): + return + self.total_usage.add(usage) + self.last_usage = MemoryRecallUsageStats.from_usage(usage) + + +@dataclass +class MemoryRecallUsageStats: + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + recorded_events: int = 0 + + @classmethod + def from_usage(cls, usage: Usage) -> MemoryRecallUsageStats: + stats = cls() + stats.add(usage) + return stats + + @property + def total_tokens(self) -> int: + return self.input_tokens + self.output_tokens + + @property + def has_recorded_usage(self) -> bool: + return self.recorded_events > 0 + + def add(self, usage: Usage) -> bool: + if _usage_is_zero(usage): + return False + self.input_tokens += int(usage.input_tokens or 0) + self.output_tokens += int(usage.output_tokens or 0) + self.cache_read_input_tokens += int(usage.cache_read_input_tokens or 0) + self.cache_creation_input_tokens += int(usage.cache_creation_input_tokens or 0) + self.recorded_events += 1 + return True + + def snapshot(self) -> dict[str, Any]: + return { + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "cache_read_input_tokens": self.cache_read_input_tokens, + "cache_creation_input_tokens": self.cache_creation_input_tokens, + "total_tokens": self.total_tokens, + "recorded_events": self.recorded_events, + "has_recorded_usage": self.has_recorded_usage, + } + + +class MemoryRecallService: + def __init__( + self, + memory_manager: MemoryManager, + provider_manager: Any, + *, + max_files: int = 5, + timeout_seconds: float = 3.0, + max_bytes_per_file: int = 12_000, + max_lines_per_file: int = 240, + is_enabled: Callable[[], bool] | None = None, + ) -> None: + self._memory_manager = memory_manager + self._provider_manager = provider_manager + self._max_files = max_files + self._timeout_seconds = timeout_seconds + self._max_bytes_per_file = max_bytes_per_file + self._max_lines_per_file = max_lines_per_file + self._is_enabled = is_enabled or is_auto_memory_enabled + self._stats = MemoryRecallStats() + self._surfaced_files: set[str] = set() + self._read_files: set[str] = set() + + def start_prefetch(self, user_input: str) -> MemoryRecallPrefetch | None: + query = user_input.strip() + if not query: + self._record("skipped", time.monotonic(), selected_files=[]) + return None + task = asyncio.create_task(self._recall(query, timeout_seconds=None)) + return MemoryRecallPrefetch(task, on_cancel=lambda: self._record_cancelled()) + + async def recall(self, user_input: str) -> MemoryRecallResult: + return await self._recall(user_input, timeout_seconds=self._timeout_seconds) + + async def _recall(self, user_input: str, *, timeout_seconds: float | None) -> MemoryRecallResult: + started = time.monotonic() + if not self._is_enabled(): + self._record("disabled", started, selected_files=[]) + return MemoryRecallResult(status="disabled") + try: + manifest = self._build_manifest() + except Exception: + self._stats.total_side_queries += 1 + self._stats.failed_side_queries += 1 + self._record("failed", started, selected_files=[], side_query=True) + return MemoryRecallResult(status="failed") + if not manifest: + self._record("skipped", started, selected_files=[]) + return MemoryRecallResult(status="skipped") + + self._stats.total_side_queries += 1 + response_usage: Usage | None = None + prompt = self._build_user_prompt(user_input, manifest) + response_text = "" + self._stats.last_usage = MemoryRecallUsageStats() + try: + completion = self._provider_manager.complete( + messages=[Message.user(prompt)], + system=self._build_system_prompt(), + tools=None, + max_tokens=512, + cache_policy="no_explicit_cache", + ) + if timeout_seconds is None: + response = await completion + else: + response = await asyncio.wait_for(completion, timeout=timeout_seconds) + usage = getattr(response, "usage", None) + if isinstance(usage, Usage): + response_usage = usage + self._stats.record_usage(usage) + response_text = str(getattr(response, "text", "")) + selected_files = self._parse_selected_files(response_text, manifest) + selected_files = self._filter_unsuppressed_files(selected_files) + except TimeoutError: + self._stats.failed_side_queries += 1 + self._record("timeout", started, selected_files=[], prompt=prompt, response=response_text, side_query=True) + return MemoryRecallResult(status="timeout") + except Exception: + self._stats.failed_side_queries += 1 + self._record("failed", started, selected_files=[], prompt=prompt, response=response_text, side_query=True) + return MemoryRecallResult(status="failed", usage=response_usage) + + content = self._read_selected_files(selected_files) + self._stats.successful_side_queries += 1 + self._stats.total_selected_files += len(selected_files) + self._record( + "success", + started, + selected_files=selected_files, + prompt=prompt, + response=response_text, + side_query=True, + ) + return MemoryRecallResult( + content=content, + selected_files=selected_files, + status="success", + usage=response_usage, + ) + + def get_stats_snapshot(self) -> dict[str, Any]: + return self._stats.snapshot() + + def reset_stats(self) -> None: + self._stats = MemoryRecallStats() + self._surfaced_files.clear() + self._read_files.clear() + + def mark_files_surfaced(self, filenames: Iterable[str]) -> None: + self._surfaced_files.update(_normalize_memory_filenames(filenames)) + + def replace_surfaced_files(self, filenames: Iterable[str]) -> None: + self._surfaced_files = _normalize_memory_filenames(filenames) + + def get_suppressed_files(self) -> set[str]: + return set(self._surfaced_files | self._read_files) + + def mark_files_read(self, filenames: Iterable[str]) -> None: + self._read_files.update(_normalize_memory_filenames(filenames)) + + def _filter_unsuppressed_files(self, filenames: list[str]) -> list[str]: + suppressed = self._surfaced_files | self._read_files + return [filename for filename in filenames if filename not in suppressed] + + def _build_manifest(self) -> dict[str, dict[str, Any]]: + manifest: dict[str, dict[str, Any]] = {} + suppressed = self._surfaced_files | self._read_files + for memory in self._list_memory_metadata(): + name = str(memory.get("name", "")).strip() + if not name: + continue + filename = f"{name}.md" + if filename in suppressed: + continue + manifest[filename] = { + "name": name, + "filename": filename, + "description": str(memory.get("description", "")).strip(), + "type": str(memory.get("type", "")).strip(), + } + return manifest + + def _list_memory_metadata(self) -> list[dict[str, Any]]: + metadata_loader = getattr(self._memory_manager, "list_memory_metadata", None) + if callable(metadata_loader): + return metadata_loader() + return self._memory_manager.list_memories() + + def _build_user_prompt(self, user_input: str, manifest: dict[str, dict[str, Any]]) -> str: + lines = [ + "User query:", + user_input, + "", + "Available memory topic files:", + ] + for item in sorted(manifest.values(), key=lambda entry: str(entry["filename"])): + lines.extend( + [ + f"- filename: {item['filename']}", + f" type: {item['type']}", + f" description: {item['description']}", + ] + ) + return "\n".join(lines) + + def _build_system_prompt(self) -> str: + return _RECALL_SYSTEM_PROMPT_TEMPLATE.format(max_files=self._max_files) + + def _parse_selected_files(self, text: str, manifest: dict[str, dict[str, Any]]) -> list[str]: + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError("Recall response must be a JSON object.") + raw_files = data.get("files", data.get("selected_files", [])) + if not isinstance(raw_files, list): + raise ValueError("Recall response files must be a list.") + + selected: list[str] = [] + for raw in raw_files: + filename = str(raw).strip() + if filename not in manifest: + continue + if not _is_safe_topic_filename(filename): + continue + if filename in selected: + continue + selected.append(filename) + if len(selected) >= self._max_files: + break + return selected + + def _read_selected_files(self, selected_files: list[str]) -> str: + if not selected_files: + return "" + + parts = ["# Recalled Memory"] + for filename in selected_files: + memory = self._memory_manager.load(Path(filename).stem) + if not memory: + continue + content = _clip_content( + str(memory.get("content", "")), + max_bytes=self._max_bytes_per_file, + max_lines=self._max_lines_per_file, + ) + parts.append( + "## {filename}\n[{type}] {description}\n\n{content}".format( + filename=filename, + type=memory.get("type", ""), + description=memory.get("description", ""), + content=content, + ) + ) + return "\n\n".join(parts) if len(parts) > 1 else "" + + def _record( + self, + status: str, + started: float, + *, + selected_files: list[str], + prompt: str = "", + response: str = "", + side_query: bool = False, + ) -> None: + self._stats.last_status = status + self._stats.last_selected_files = list(selected_files) + duration_ms = max(0, int((time.monotonic() - started) * 1000)) + self._stats.last_duration_ms = duration_ms + self._stats.last_prompt_preview = _preview(prompt) + self._stats.last_response_preview = _preview(response) + self._stats.last_prompt_chars = len(prompt) + self._stats.last_response_chars = len(response) + if side_query: + self._stats.last_side_query_status = status + self._stats.last_side_query_selected_files = list(selected_files) + self._stats.last_side_query_duration_ms = duration_ms + + def _record_cancelled(self) -> None: + self._stats.cancelled_side_queries += 1 + attempted_queries = ( + self._stats.successful_side_queries + self._stats.failed_side_queries + self._stats.cancelled_side_queries + ) + if self._stats.total_side_queries < attempted_queries: + self._stats.total_side_queries = attempted_queries + self._stats.last_status = "cancelled" + self._stats.last_selected_files = [] + self._stats.last_side_query_status = "cancelled" + self._stats.last_side_query_selected_files = [] + self._stats.last_side_query_duration_ms = 0 + + +def _is_safe_topic_filename(filename: str) -> bool: + path = Path(filename) + return path.name == filename and path.suffix == ".md" and path.stem not in {"", ".", ".."} + + +def _normalize_memory_filenames(filenames: Iterable[str]) -> set[str]: + normalized: set[str] = set() + for raw in filenames: + value = str(raw).strip() + if not value: + continue + filename = value if value.endswith(".md") else f"{value}.md" + if _is_safe_topic_filename(filename): + normalized.add(filename) + return normalized + + +def _usage_is_zero(usage: Usage) -> bool: + return ( + int(usage.input_tokens or 0) == 0 + and int(usage.output_tokens or 0) == 0 + and int(usage.cache_read_input_tokens or 0) == 0 + and int(usage.cache_creation_input_tokens or 0) == 0 + ) + + +def _preview(text: str, *, limit: int = 2048) -> str: + if len(text) <= limit: + return text + return text[:limit] + "\n...[truncated]" + + +def _clip_content(content: str, *, max_bytes: int, max_lines: int) -> str: + lines = content.splitlines()[:max_lines] + clipped = "\n".join(lines) + encoded = clipped.encode("utf-8") + if len(encoded) <= max_bytes: + return clipped + return encoded[:max_bytes].decode("utf-8", errors="ignore") + + +_RECALL_SYSTEM_PROMPT_TEMPLATE = ( + "Select clearly relevant memory topic files for the user's current query. " + 'Return only JSON in this exact shape: {{"files": ["name.md"]}}. ' + "Use only filenames from the manifest. Select at most {max_files} files. " + "Return an empty list when nothing is clearly relevant." +) diff --git a/src/iac_code/providers/anthropic_provider.py b/src/iac_code/providers/anthropic_provider.py index 8b5664a..2f66883 100644 --- a/src/iac_code/providers/anthropic_provider.py +++ b/src/iac_code/providers/anthropic_provider.py @@ -179,6 +179,7 @@ async def complete( system: str, tools: list[ToolDefinition] | None = None, max_tokens: int = 8192, + cache_policy: str = "default", ) -> NonStreamingResponse: kwargs = self._build_kwargs(messages, system, tools, max_tokens) response = await self._client.messages.create(**kwargs) @@ -236,17 +237,36 @@ def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]: """Convert internal ``Message`` list to Anthropic API format.""" result: list[dict[str, Any]] = [] for msg in messages: - if isinstance(msg.content, str): - result.append({"role": msg.role, "content": msg.content}) - elif isinstance(msg.content, list): - blocks: list[dict[str, Any]] = [] - for block in msg.content: - blocks.append(self._convert_content_block(block)) - result.append({"role": msg.role, "content": blocks}) + converted = {"role": msg.role, "content": self._convert_message_content(msg.content)} + if result and result[-1]["role"] == converted["role"]: + result[-1]["content"] = self._merge_message_content(result[-1]["content"], converted["content"]) else: - result.append({"role": msg.role, "content": msg.content}) + result.append(converted) return result + def _convert_message_content(self, content: Any) -> Any: + if isinstance(content, str): + return content + if isinstance(content, list): + return [self._convert_content_block(block) for block in content] + return content + + @classmethod + def _merge_message_content(cls, left: Any, right: Any) -> str | list[dict[str, Any]]: + if isinstance(left, str) and isinstance(right, str): + if left and right: + return f"{left}\n\n{right}" + return left or right + return [*cls._content_to_blocks(left), *cls._content_to_blocks(right)] + + @staticmethod + def _content_to_blocks(content: Any) -> list[dict[str, Any]]: + if isinstance(content, list): + return content + if isinstance(content, str): + return [{"type": "text", "text": content}] + return [{"type": "text", "text": str(content)}] + @staticmethod def _convert_content_block(block: ContentBlock) -> dict[str, Any]: """Convert a single ``ContentBlock`` to Anthropic dict.""" diff --git a/src/iac_code/providers/base.py b/src/iac_code/providers/base.py index 4863c1d..5882cfa 100644 --- a/src/iac_code/providers/base.py +++ b/src/iac_code/providers/base.py @@ -107,6 +107,7 @@ async def complete( system: str, tools: list[ToolDefinition] | None = None, max_tokens: int = 8192, + cache_policy: str = "default", ) -> NonStreamingResponse: ... @abstractmethod diff --git a/src/iac_code/providers/dashscope_provider.py b/src/iac_code/providers/dashscope_provider.py index baf9efe..19a1f39 100644 --- a/src/iac_code/providers/dashscope_provider.py +++ b/src/iac_code/providers/dashscope_provider.py @@ -4,6 +4,7 @@ from typing import Any, cast +from iac_code.agent.message import RECALLED_MEMORY_MARKER from iac_code.agent.system_prompt import split_by_dynamic_boundary from iac_code.providers.base import Message from iac_code.providers.openai_provider import OpenAIProvider @@ -16,6 +17,8 @@ # Prefix-matched against the model name. Extend when new models are added. # Ref: https://help.aliyun.com/zh/model-studio/context-cache _EXPLICIT_CACHE_MODEL_PREFIXES: tuple[str, ...] = ( + "qwen3.7-max", + "qwen3.7-plus", "qwen3-coder-plus", "qwen3-coder-flash", "qwen3.5-plus", @@ -26,6 +29,8 @@ "qwen-flash", ) +_RECALLED_MEMORY_REMINDER_PREFIX = f"\n{RECALLED_MEMORY_MARKER}:" + class DashScopeProvider(OpenAIProvider): """Provider backed by Aliyun DashScope's OpenAI-compatible endpoint. @@ -64,10 +69,12 @@ def _build_api_messages( self, messages: list[Message], system: str, + cache_policy: str = "default", ) -> list[dict[str, Any]]: api_messages: list[dict[str, Any]] = [] + explicit_cache_enabled = cache_policy != "no_explicit_cache" and self._supports_explicit_cache() if system: - if self._supports_explicit_cache(): + if explicit_cache_enabled: static_part, dynamic_part = split_by_dynamic_boundary(system) content_blocks: list[dict[str, Any]] = [ {"type": "text", "text": static_part, "cache_control": {"type": "ephemeral"}}, @@ -79,7 +86,7 @@ def _build_api_messages( api_messages.append({"role": "system", "content": system}) api_messages.extend(self._convert_messages(messages)) - if self._supports_explicit_cache(): + if explicit_cache_enabled: self._mark_last_user_message_cacheable(api_messages) return api_messages @@ -97,6 +104,8 @@ def _mark_last_user_message_cacheable(api_messages: list[dict[str, Any]]) -> Non if msg.get("role") != "user": continue content = msg.get("content") + if _is_recalled_memory_reminder(content): + continue if isinstance(content, str): msg["content"] = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}] elif isinstance(content, list): @@ -116,3 +125,13 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: if spec.family is not ThinkingFamily.DASHSCOPE: return {} return {"extra_body": {"enable_thinking": True}} + + +def _is_recalled_memory_reminder(content: Any) -> bool: + if isinstance(content, str): + return content.startswith(_RECALLED_MEMORY_REMINDER_PREFIX) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and str(block.get("text") or "").startswith(_RECALLED_MEMORY_REMINDER_PREFIX): + return True + return False diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index b7cb706..5d9166a 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -435,9 +435,21 @@ def _emit_failure_telemetry(provider_name: str, model: str, started: float, exc: ) async def complete( - self, messages: list[Message], system: str, tools: list[ToolDefinition] | None = None, max_tokens: int = 8192 + self, + messages: list[Message], + system: str, + tools: list[ToolDefinition] | None = None, + max_tokens: int = 8192, + cache_policy: str = "default", ) -> NonStreamingResponse: - return await self._complete_with_retry(messages, system, tools, max_tokens, is_fallback=False) + return await self._complete_with_retry( + messages, + system, + tools, + max_tokens, + is_fallback=False, + cache_policy=cache_policy, + ) async def _complete_with_retry( self, @@ -448,6 +460,7 @@ async def _complete_with_retry( is_fallback=False, provider_override: Provider | None = None, model_override: str | None = None, + cache_policy: str = "default", ) -> NonStreamingResponse: result = await self._complete_with_retry_result( messages, @@ -457,6 +470,7 @@ async def _complete_with_retry( is_fallback=is_fallback, provider_override=provider_override, model_override=model_override, + cache_policy=cache_policy, ) return result.response @@ -469,6 +483,7 @@ async def _complete_with_retry_result( is_fallback=False, provider_override: Provider | None = None, model_override: str | None = None, + cache_policy: str = "default", ) -> _CompletionResult: provider = provider_override or self._ensure_provider() model = model_override or self._model @@ -488,7 +503,8 @@ async def _on_retry(attempt, exc, delay): async def operation(): try: - response = await provider.complete(messages, system, tools, max_tokens) + kwargs = {"cache_policy": cache_policy} if cache_policy != "default" else {} + response = await provider.complete(messages, system, tools, max_tokens, **kwargs) return _CompletionResult(response=response, model=model, provider_name=provider_name) except Exception as e: status = getattr(e, "status_code", None) or getattr(e, "status", None) @@ -527,6 +543,7 @@ async def operation(): is_fallback=True, provider_override=fallback_provider, model_override=fallback, + cache_policy=cache_policy, ) except Exception: raise original_exc from None diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index f7ce686..9eaaafe 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -185,6 +185,7 @@ def _build_api_messages( self, messages: list[Message], system: str, + cache_policy: str = "default", ) -> list[dict[str, Any]]: """Build the ``messages`` list sent to the OpenAI Chat API. @@ -325,8 +326,9 @@ async def complete( system: str, tools: list[ToolDefinition] | None = None, max_tokens: int = 8192, + cache_policy: str = "default", ) -> NonStreamingResponse: - api_messages = self._build_api_messages(messages, system) + api_messages = self._build_api_messages(messages, system, cache_policy=cache_policy) kwargs: dict[str, Any] = { "model": self._model, diff --git a/src/iac_code/services/agent_factory.py b/src/iac_code/services/agent_factory.py index 9d27108..847206d 100644 --- a/src/iac_code/services/agent_factory.py +++ b/src/iac_code/services/agent_factory.py @@ -3,6 +3,7 @@ import os import uuid from dataclasses import dataclass +from datetime import datetime from typing import Any @@ -27,6 +28,7 @@ class AgentRuntime: command_registry: Any task_manager: Any memory_manager: Any + legacy_memory_manager: Any def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: @@ -40,6 +42,8 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: from iac_code.config import get_config_dir, load_credentials from iac_code.memory.memory_manager import MemoryManager from iac_code.memory.memory_tools import ReadMemoryTool, WriteMemoryTool + from iac_code.memory.project_memory import ProjectMemoryRuntime + from iac_code.memory.recall import MemoryRecallService from iac_code.providers.manager import ProviderManager from iac_code.services.cloud_credentials import CloudCredentials from iac_code.services.session_storage import SessionStorage @@ -57,6 +61,7 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: cwd = options.cwd or os.getcwd() session_id = options.session_id or str(uuid.uuid4())[:8] + runtime_current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") credentials = load_credentials(model=options.model) @@ -101,8 +106,10 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: session_storage = SessionStorage() - memory_manager = MemoryManager(memory_dir=str(get_config_dir() / "memory")) - memory_content = memory_manager.get_prompt_content() + memory_runtime = ProjectMemoryRuntime(cwd) + memory_manager = memory_runtime.memory_manager + legacy_memory_manager = MemoryManager(memory_dir=str(get_config_dir() / "memory")) + memory_recall_service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider_manager) tool_registry.register(ReadMemoryTool(memory_manager)) tool_registry.register(WriteMemoryTool(memory_manager)) @@ -112,7 +119,15 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: tool_registry.register(TaskStopTool(task_manager)) notification_queue = NotificationQueue() - base_system_prompt = build_system_prompt(cwd=cwd, memory_content=memory_content) + + def build_base_system_prompt() -> str: + return build_system_prompt( + cwd=cwd, + memory_context=memory_runtime.build_memory_context(), + current_time=runtime_current_time, + ) + + base_system_prompt = build_base_system_prompt() tool_registry.register( AgentTool( task_manager=task_manager, @@ -162,9 +177,18 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: setattr(agent_tool, "_permission_context", permission_context) skill_listing = build_skill_listing(command_registry.get_model_invocable_skills()) + + def build_agent_system_prompt() -> str: + return build_system_prompt( + cwd=cwd, + memory_context=memory_runtime.build_memory_context(), + skill_listing=skill_listing, + current_time=runtime_current_time, + ) + agent_loop = AgentLoop( provider_manager=provider_manager, - system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content, skill_listing=skill_listing), + system_prompt=build_agent_system_prompt(), tool_registry=tool_registry, session_storage=session_storage, session_id=session_id, @@ -173,6 +197,8 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: cwd=cwd, permission_context=permission_context, auto_trigger_skills=command_registry.get_model_invocable_skills(), + memory_recall_service=memory_recall_service, + system_prompt_refresher=build_agent_system_prompt, ) return AgentRuntime( @@ -183,4 +209,5 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: command_registry=command_registry, task_manager=task_manager, memory_manager=memory_manager, + legacy_memory_manager=legacy_memory_manager, ) diff --git a/src/iac_code/services/context_manager.py b/src/iac_code/services/context_manager.py index f1de573..3d771d6 100644 --- a/src/iac_code/services/context_manager.py +++ b/src/iac_code/services/context_manager.py @@ -7,7 +7,16 @@ from loguru import logger -from iac_code.agent.message import ContentBlock, Conversation, Message, ToolResultBlock, ToolUseBlock +from iac_code.agent.message import ( + ContentBlock, + Conversation, + Message, + ToolResultBlock, + ToolUseBlock, + create_recalled_memory_message, + get_recalled_memory_files, + is_recalled_memory_message, +) from iac_code.services.token_counter import TokenCounter @@ -113,6 +122,12 @@ def add_tool_results(self, tool_results: list[ToolResultBlock]) -> Message: msg.token_count = self._token_counter.count_message(msg.to_api_format()) return msg + def add_recalled_memory_message(self, content: str, selected_files: list[str]) -> Message: + msg = create_recalled_memory_message(content, selected_files) + self._conversation.messages.append(msg) + msg.token_count = self._token_counter.count_message(msg.to_api_format()) + return msg + def add_raw_message(self, raw_msg: dict[str, Any]) -> Message: """Add a raw message dict (e.g. from ToolResult.new_messages) to the conversation.""" role = raw_msg.get("role", "user") @@ -135,6 +150,12 @@ def get_messages(self) -> list[Message]: def get_api_messages(self) -> list[dict[str, Any]]: return self._conversation.to_api_messages() + def get_surfaced_memory_files(self) -> set[str]: + files: set[str] = set() + for msg in self._conversation.messages: + files.update(get_recalled_memory_files(msg)) + return files + def get_total_tokens(self) -> int: return self._system_prompt_tokens + self._tool_definition_tokens + self._conversation.get_total_tokens() @@ -233,10 +254,14 @@ def build_compaction_prompt(self) -> str: conversation_text = [] for msg in old_messages: + if is_recalled_memory_message(msg): + continue role = msg.role.upper() text = msg.get_text() if text: conversation_text.append(f"{role}: {text}") + if not conversation_text: + return "" joined = "\n".join(conversation_text) return ( diff --git a/src/iac_code/services/session_index.py b/src/iac_code/services/session_index.py index 986a508..6daba1d 100644 --- a/src/iac_code/services/session_index.py +++ b/src/iac_code/services/session_index.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from pathlib import Path +from iac_code.agent.message import RECALLED_MEMORY_MARKER, RECALLED_MEMORY_METADATA_TYPE from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, read_session_metadata from iac_code.utils.project_paths import ( get_project_dir, @@ -156,6 +157,18 @@ def read_head_and_tail(path: Path, size: int | None = None) -> tuple[str, str]: _USER_ROLE_PATTERNS = (re.compile(r'"role"\s*:\s*"user"'),) +def _is_recalled_memory_text(text: str | None) -> bool: + return bool(text and RECALLED_MEMORY_MARKER in text) + + +def _is_recalled_memory_row(obj: dict) -> bool: + metadata = obj.get("metadata") + if isinstance(metadata, dict) and metadata.get("type") == RECALLED_MEMORY_METADATA_TYPE: + return True + content = obj.get("content") + return isinstance(content, str) and _is_recalled_memory_text(content) + + def _extract_first_user_text(head: str) -> str | None: """Find the first user message's text in a head chunk. @@ -174,6 +187,8 @@ def _extract_first_user_text(head: str) -> str | None: continue if not isinstance(obj, dict) or obj.get("role") != "user": continue + if _is_recalled_memory_row(obj): + continue content = obj.get("content") if isinstance(content, str) and content.strip(): return content @@ -212,6 +227,8 @@ def read_lite_metadata(path: Path) -> LiteMetadata: head, "git_branch" ) last_prompt = extract_last_json_string_field(tail, "last_prompt") + if _is_recalled_memory_text(last_prompt): + last_prompt = None first_prompt = _extract_first_user_text(head) return LiteMetadata( cwd=cwd, diff --git a/src/iac_code/skills/skill_tool.py b/src/iac_code/skills/skill_tool.py index 137ee29..d992127 100644 --- a/src/iac_code/skills/skill_tool.py +++ b/src/iac_code/skills/skill_tool.py @@ -44,6 +44,9 @@ def __init__( self._normalize_name(name): command for name, command in (disabled_skills or {}).items() } + def set_system_prompt(self, system_prompt: str) -> None: + self._system_prompt = system_prompt + @property def name(self) -> str: return "skill" diff --git a/src/iac_code/ui/dialogs/memory.py b/src/iac_code/ui/dialogs/memory.py new file mode 100644 index 0000000..c49a7b5 --- /dev/null +++ b/src/iac_code/ui/dialogs/memory.py @@ -0,0 +1,150 @@ +"""Memory command dialog.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from rich.console import Group, RenderableType +from rich.text import Text + +from iac_code.i18n import _ +from iac_code.ui.core.key_event import KeyEvent + +MemoryAction = str + + +@dataclass +class _MemoryOption: + label: str + value: MemoryAction + description: str + + +class MemoryDialog: + """Claude-style memory selector with a focusable auto-memory toggle.""" + + def __init__( + self, + *, + project_path: Path, + user_path: Path, + auto_memory_dir: Path, + auto_memory_enabled: bool, + initial_focus_action: MemoryAction | None = None, + on_toggle: Callable[[bool], None] | None = None, + ) -> None: + self.project_path = project_path + self.user_path = user_path + self.auto_memory_dir = auto_memory_dir + self.auto_memory_enabled = auto_memory_enabled + self._on_toggle = on_toggle + self.focused_index = self._focus_index_for_action(initial_focus_action) + self.result: MemoryAction | None = None + self.done = False + + def run(self) -> MemoryAction | None: + """Run the dialog in raw terminal mode.""" + from rich.console import Console + + from iac_code.ui.core.in_place_render import InPlaceRenderer + from iac_code.ui.core.raw_input import RawInputCapture + + renderer = InPlaceRenderer(Console()) + try: + with RawInputCapture() as cap: + while not self.done: + renderer.render(self.render()) + key_event = cap.read_key(timeout=0.1) + if key_event is not None: + self.handle_key(key_event) + finally: + renderer.clear() + return self.result + + def render(self) -> RenderableType: + return Group(*(Text(line) for line in self.render_lines())) + + def render_lines(self) -> list[str]: + lines = [ + " " + _("Memory"), + "", + self._format_row( + _("Auto-memory: {state}").format(state=_("on") if self.auto_memory_enabled else _("off")), 0 + ), + "", + ] + for index, option in enumerate(self._options(), start=1): + label = "{index}. {label}".format(index=index, label=option.label) + padding = max(2, 28 - _display_width(label)) + lines.append(self._format_row(label + (" " * padding) + option.description, index)) + lines.extend(["", " " + _("Enter to confirm · Esc to cancel")]) + return lines + + def handle_key(self, key_event: KeyEvent) -> bool: + key = key_event.key + ctrl = key_event.ctrl + options = self._options() + if key == "up" or (ctrl and key == "p"): + self.focused_index = max(0, self.focused_index - 1) + return True + if key == "down" or (ctrl and key == "n"): + self.focused_index = min(len(options), self.focused_index + 1) + return True + if key == "enter": + if self.focused_index == 0: + self.auto_memory_enabled = not self.auto_memory_enabled + self.focused_index = min(self.focused_index, len(self._options())) + if self._on_toggle is not None: + self._on_toggle(self.auto_memory_enabled) + return True + self.result = options[self.focused_index - 1].value + self.done = True + return True + if key == "escape": + self.result = None + self.done = True + return True + return False + + def _format_row(self, content: str, index: int) -> str: + marker = "❯" if self.focused_index == index else " " + return " {marker} {content}".format(marker=marker, content=content) + + def _options(self) -> list[_MemoryOption]: + options = [ + _MemoryOption( + label=_("Project memory"), + value="project", + description=_("Saved in {path}").format(path=self.project_path), + ), + _MemoryOption( + label=_("User memory"), + value="user", + description=_("Saved in {path}").format(path=self.user_path), + ), + ] + if self.auto_memory_enabled: + options.append( + _MemoryOption( + label=_("Open auto-memory folder"), + value="folder", + description=str(self.auto_memory_dir), + ) + ) + return options + + def _focus_index_for_action(self, action: MemoryAction | None) -> int: + if action is None: + return 0 + for index, option in enumerate(self._options(), start=1): + if option.value == action: + return index + return 0 + + +def _display_width(text: str) -> int: + from rich.cells import cell_len + + return cell_len(text) diff --git a/src/iac_code/ui/dialogs/memory_editor.py b/src/iac_code/ui/dialogs/memory_editor.py new file mode 100644 index 0000000..81be667 --- /dev/null +++ b/src/iac_code/ui/dialogs/memory_editor.py @@ -0,0 +1,341 @@ +"""Small Vim-like editor for IAC-CODE.md memory files.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass + +from rich.cells import cell_len +from rich.console import Console, Group, RenderableType +from rich.text import Text + +from iac_code.i18n import _ +from iac_code.ui.core.key_event import KeyEvent + + +@dataclass(frozen=True) +class MemoryEditResult: + status: str + content: str + + +class FullscreenRenderer: + """Alternate-screen renderer that preserves and displays the hardware cursor.""" + + def __init__(self, console: Console) -> None: + self._console = console + + def __enter__(self) -> "FullscreenRenderer": + out = self._console.file + out.write("\x1b[?1049h\x1b[?25h\x1b[2J\x1b[H") + out.flush() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + out = self._console.file + out.write("\x1b[?25h\x1b[?1049l") + out.flush() + + def render(self, renderable: RenderableType, cursor_to: tuple[int, int] | None = None) -> None: + with self._console.capture() as capture: + self._console.print(renderable) + captured = capture.get().replace("\r\n", "\n") + if captured.endswith("\n"): + captured = captured[:-1] + text = captured.replace("\n", "\r\n") + out = self._console.file + out.write("\x1b[H\x1b[2J") + out.write(text) + if cursor_to is not None: + row, col = cursor_to + out.write(f"\x1b[{row + 1};{col + 1}H") + out.flush() + + +class VimMemoryEditor: + """A focused Vim-like multiline editor for memory files. + + This intentionally implements a compact subset: normal/insert/command + modes, movement, insertion, x, dd, :wq, and :q!. + """ + + def __init__(self, initial_text: str, *, title: str, path: str | None = None) -> None: + self._original_text = initial_text + self.title = title + self.path = path or "" + self.lines = initial_text.split("\n") if initial_text else [""] + self.row = 0 + self.col = 0 + self.mode = "normal" + self.command = "" + self._pending_d = False + self.done = False + self.result: MemoryEditResult | None = None + + def run(self, *, renderer=None, input_capture=None) -> MemoryEditResult: + from iac_code.ui.core.raw_input import RawInputCapture + + renderer_context = renderer or FullscreenRenderer(Console()) + input_context = input_capture or RawInputCapture() + try: + with renderer_context as active_renderer, input_context as cap: + dirty = True + while not self.done: + if dirty: + active_renderer.render(self.render(), cursor_to=self.cursor_position()) + dirty = False + key_event = cap.read_key(timeout=0.1) + if key_event is not None: + dirty = self.handle_key(key_event) or dirty + except OSError: + return MemoryEditResult("cancelled", self._original_text) + return self.result or MemoryEditResult("cancelled", self._original_text) + + def render(self) -> RenderableType: + return Group(*self._render_rows()) + + def render_lines(self) -> list[str]: + return [row.plain for row in self._render_rows()] + + def _render_rows(self) -> list[Text]: + width = self._terminal_width() + visible_from = self._visible_from() + body = self.lines[visible_from : visible_from + self._visible_height()] + rows = [Text(self._top_line(width), style="bold white on #202b36")] + for row_index in range(self._visible_height()): + source_index = visible_from + row_index + if row_index < len(body): + rows.append(self._body_line(source_index + 1, body[row_index], width)) + else: + rows.append(self._body_line(None, "", width)) + rows.append(self._bottom_line(width)) + return rows + + def handle_key(self, key_event: KeyEvent) -> bool: + if self.done: + return True + if self.mode == "insert": + return self._handle_insert(key_event) + if self.mode == "command": + return self._handle_command(key_event) + return self._handle_normal(key_event) + + def content(self) -> str: + return "\n".join(self.lines) + + def cursor_position(self) -> tuple[int, int]: + visible_from = self._visible_from() + if self.mode == "command": + return 1 + self._visible_height(), 2 + cell_len(self.command) + visual_col = cell_len(self.lines[self.row][: self.col]) + return 1 + max(0, self.row - visible_from), self._content_column() + visual_col + + def _handle_insert(self, key_event: KeyEvent) -> bool: + key = key_event.key + if key == "escape": + self.mode = "normal" + self._clamp_cursor() + return True + if key == "enter": + current = self.lines[self.row] + self.lines[self.row] = current[: self.col] + self.lines.insert(self.row + 1, current[self.col :]) + self.row += 1 + self.col = 0 + return True + if key == "backspace": + self._backspace() + return True + if key == "delete": + self._delete_char() + return True + if key in {"left", "right", "up", "down"}: + self._move(key) + return True + if key_event.char and not key_event.ctrl: + self._insert_text(key_event.char) + return True + return False + + def _handle_command(self, key_event: KeyEvent) -> bool: + key = key_event.key + if key == "escape": + self.mode = "normal" + self.command = "" + return True + if key == "backspace": + self.command = self.command[:-1] + return True + if key == "enter": + command = self.command.strip() + if command == "wq": + content = self.content() + status = "unchanged" if content == self._original_text else "saved" + self.result = MemoryEditResult(status, content) + self.done = True + elif command == "q!": + self.result = MemoryEditResult("cancelled", self._original_text) + self.done = True + else: + self.command = "" + self.mode = "normal" + return True + if key_event.char and not key_event.ctrl: + self.command += key_event.char + return True + return False + + def _handle_normal(self, key_event: KeyEvent) -> bool: + key = key_event.key + if key in {"left", "right", "up", "down"}: + self._pending_d = False + self._move(key) + return True + if key in {"h", "j", "k", "l"}: + self._pending_d = False + self._move({"h": "left", "j": "down", "k": "up", "l": "right"}[key]) + return True + if key == "i": + self._pending_d = False + self.mode = "insert" + return True + if key == "a": + self._pending_d = False + self.col = min(len(self.lines[self.row]), self.col + 1) + self.mode = "insert" + return True + if key == "o": + self._pending_d = False + self.lines.insert(self.row + 1, "") + self.row += 1 + self.col = 0 + self.mode = "insert" + return True + if key == "x": + self._pending_d = False + self._delete_char() + return True + if key == "d": + if self._pending_d: + self._delete_line() + self._pending_d = False + else: + self._pending_d = True + return True + if key == ":": + self._pending_d = False + self.command = "" + self.mode = "command" + return True + self._pending_d = False + return False + + def _insert_text(self, text: str) -> None: + line = self.lines[self.row] + self.lines[self.row] = line[: self.col] + text + line[self.col :] + self.col += len(text) + + def _backspace(self) -> None: + if self.col > 0: + line = self.lines[self.row] + self.lines[self.row] = line[: self.col - 1] + line[self.col :] + self.col -= 1 + return + if self.row > 0: + previous_len = len(self.lines[self.row - 1]) + self.lines[self.row - 1] += self.lines.pop(self.row) + self.row -= 1 + self.col = previous_len + + def _delete_char(self) -> None: + line = self.lines[self.row] + if self.col < len(line): + self.lines[self.row] = line[: self.col] + line[self.col + 1 :] + + def _delete_line(self) -> None: + if len(self.lines) == 1: + self.lines[0] = "" + self.row = 0 + self.col = 0 + return + self.lines.pop(self.row) + self.row = min(self.row, len(self.lines) - 1) + self._clamp_cursor() + + def _move(self, direction: str) -> None: + if direction == "left": + self.col = max(0, self.col - 1) + elif direction == "right": + self.col = min(len(self.lines[self.row]), self.col + 1) + elif direction == "up": + self.row = max(0, self.row - 1) + self._clamp_cursor() + elif direction == "down": + self.row = min(len(self.lines) - 1, self.row + 1) + self._clamp_cursor() + + def _clamp_cursor(self) -> None: + self.col = min(self.col, len(self.lines[self.row])) + + def _visible_height(self) -> int: + return max(1, shutil.get_terminal_size((80, 24)).lines - 2) + + def _visible_from(self) -> int: + height = self._visible_height() + if self.row < height: + return 0 + return min(self.row, max(0, len(self.lines) - height)) + + def _status_label(self) -> str: + if self.mode == "insert": + return _("INSERT") + if self.mode == "command": + return ":" + self.command + return _("NORMAL") + + def _top_line(self, width: int) -> str: + left = " " + self.title + if not self.path: + return _fit_cells(left, width) + right = self.path + gap = max(1, width - cell_len(left) - cell_len(right)) + return _fit_cells(left + (" " * gap) + right, width) + + def _body_line(self, line_number: int | None, content: str, width: int) -> Text: + gutter = self._line_number_width() + prefix = "{line_number:>{gutter}} │ ".format( + line_number="" if line_number is None else line_number, + gutter=gutter, + ) + prefix_width = min(width, cell_len(prefix)) + body_width = max(0, width - prefix_width) + return Text.assemble( + (_fit_cells(prefix, prefix_width), "bold #667786 on #141b22"), + (_fit_cells(content, body_width), "#dce8ef on #17202a"), + ) + + def _bottom_line(self, width: int) -> Text: + status = self._status_label() + hint = _("{status} :wq save · :q! discard").format(status=status) + return Text(_fit_cells(" " + hint, width), style="#b8c8d5 on #10161c") + + def _content_column(self) -> int: + return self._line_number_width() + cell_len(" │ ") + + def _line_number_width(self) -> int: + return max(2, len(str(len(self.lines)))) + + def _terminal_width(self) -> int: + return max(1, shutil.get_terminal_size((80, 24)).columns - 1) + + +def _fit_cells(text: str, width: int) -> str: + used = 0 + output = [] + for char in text: + char_width = cell_len(char) + if used + char_width > width: + break + output.append(char) + used += char_width + return "".join(output) + (" " * max(0, width - used)) diff --git a/src/iac_code/ui/dialogs/resume_picker.py b/src/iac_code/ui/dialogs/resume_picker.py index 7561431..c8bc484 100644 --- a/src/iac_code/ui/dialogs/resume_picker.py +++ b/src/iac_code/ui/dialogs/resume_picker.py @@ -26,7 +26,7 @@ from rich.console import Console, Group, RenderableType from rich.text import Text -from iac_code.agent.message import Message, ToolResultBlock +from iac_code.agent.message import Message, ToolResultBlock, is_recalled_memory_message from iac_code.i18n import _, ngettext from iac_code.services.session_index import SessionEntry, SessionIndex from iac_code.ui.components.fuzzy_picker import fuzzy_match @@ -676,6 +676,8 @@ def _fallback_render(console: Console, messages: list[Message]) -> None: """Minimal renderer used in tests / when no live renderer is provided.""" first = True for msg in messages: + if is_recalled_memory_message(msg): + continue if not first: console.print() first = False diff --git a/src/iac_code/ui/renderer.py b/src/iac_code/ui/renderer.py index fa23580..eb3e4c0 100644 --- a/src/iac_code/ui/renderer.py +++ b/src/iac_code/ui/renderer.py @@ -1633,11 +1633,13 @@ def _format_token_count(count: int, label: str) -> str: def replay_history(self, messages: list) -> None: """Replay saved Message objects to scrollback with 1:1 visual fidelity.""" - from iac_code.agent.message import TextBlock, ToolResultBlock, ToolUseBlock + from iac_code.agent.message import TextBlock, ToolResultBlock, ToolUseBlock, is_recalled_memory_message # Build a lookup of tool_use_id → ToolResultBlock from all user messages tool_results: dict[str, ToolResultBlock] = {} for msg in messages: + if is_recalled_memory_message(msg): + continue if msg.role == "user" and isinstance(msg.content, list): for block in msg.content: if isinstance(block, ToolResultBlock): @@ -1645,6 +1647,8 @@ def replay_history(self, messages: list) -> None: first_turn = True for msg in messages: + if is_recalled_memory_message(msg): + continue if msg.role == "user": is_tool_result_only = isinstance(msg.content, list) and all( isinstance(b, ToolResultBlock) for b in msg.content diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 375ceca..ca344c3 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -20,6 +20,7 @@ import sys import time from dataclasses import dataclass +from datetime import datetime from types import ModuleType from typing import Any, cast @@ -33,6 +34,8 @@ from iac_code.config import get_active_provider_key, get_config_dir, get_history_path, load_credentials from iac_code.i18n import _ from iac_code.memory.memory_manager import MemoryManager +from iac_code.memory.project_memory import ProjectMemoryRuntime +from iac_code.memory.recall import MemoryRecallService from iac_code.providers.manager import ProviderManager from iac_code.providers.registry import PROVIDER_REGISTRY from iac_code.services.session_index import SessionIndex @@ -116,6 +119,7 @@ def __init__( # `cd` mid-session via Bash, but those changes must not relocate the # session file or split it across two project dirs. self._original_cwd = os.getcwd() + self._runtime_current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.store = AppStateStore(initial_state=AppState(model=model)) self.command_registry = create_default_registry() self.tool_registry = ToolRegistry() @@ -151,23 +155,31 @@ def __init__( self._command_log: list[tuple[str, str, int, bool]] = [] self._streaming_error_log: list[tuple[str, int]] = [] - memory_dir = str(get_config_dir() / "memory") - self._memory_manager = MemoryManager(memory_dir=memory_dir) + legacy_memory_dir = str(get_config_dir() / "memory") + self._legacy_memory_manager = MemoryManager(memory_dir=legacy_memory_dir) + self._memory_runtime = ProjectMemoryRuntime(self._original_cwd) + self._memory_manager = self._memory_runtime.memory_manager + self._memory_recall_service = MemoryRecallService( + memory_manager=self._memory_manager, + provider_manager=self._provider_manager, + ) # Register new tools from iac_code.agent.agent_tool import AgentTool from iac_code.memory.memory_tools import ReadMemoryTool, WriteMemoryTool from iac_code.tasks.task_tools import TaskGetTool, TaskListTool, TaskStopTool - memory_content = "" - if hasattr(self, "_memory_manager") and self._memory_manager: - memory_content = self._memory_manager.get_prompt_content() + memory_context = self._refresh_memory_context() self.tool_registry.register( AgentTool( task_manager=self._task_manager, provider_manager=self._provider_manager, tool_registry=self.tool_registry, - system_prompt=build_system_prompt(cwd=os.getcwd(), memory_content=memory_content), + system_prompt=build_system_prompt( + cwd=os.getcwd(), + memory_context=memory_context, + current_time=self._runtime_current_time, + ), notification_queue=self._notification_queue, ) ) @@ -178,7 +190,6 @@ def __init__( self.tool_registry.register(TaskStopTool(self._task_manager)) cwd = os.getcwd() - self._memory_content = memory_content self.refresh_skills() skill_commands = self.command_registry.get_model_invocable_skills() @@ -201,7 +212,10 @@ def __init__( self._agent_loop = AgentLoop( provider_manager=self._provider_manager, system_prompt=build_system_prompt( - cwd=cwd, memory_content=memory_content, skill_listing=self._skill_listing + cwd=cwd, + memory_context=memory_context, + skill_listing=self._skill_listing, + current_time=self._runtime_current_time, ), tool_registry=self.tool_registry, session_storage=self._session_storage, @@ -211,6 +225,8 @@ def __init__( permission_context=permission_context, permission_context_getter=lambda: self.store.get_state().permission_context, auto_trigger_skills=skill_commands, + memory_recall_service=self._memory_recall_service, + system_prompt_refresher=self._build_current_system_prompt, ) self.renderer = Renderer( self.console, @@ -229,7 +245,7 @@ def __init__( cwd = os.getcwd() self._suggestion_aggregator = SuggestionAggregator( [ - CommandProvider(self.command_registry, memory_manager=self._memory_manager), + CommandProvider(self.command_registry, memory_manager=self._legacy_memory_manager), SkillProvider(self.command_registry), FileProvider(cwd), DirectoryProvider(cwd), @@ -273,6 +289,28 @@ def refresh_cloud_tools(self) -> None: register_cloud_tools(self.tool_registry, CloudCredentials()) + def _refresh_memory_context(self): + runtime = getattr(self, "_memory_runtime", None) + if runtime is None: + return getattr(self, "_memory_context", None) + self._memory_context = runtime.build_memory_context() + return self._memory_context + + def _build_current_system_prompt(self) -> str: + return build_system_prompt( + cwd=os.getcwd(), + memory_context=self._refresh_memory_context(), + skill_listing=getattr(self, "_skill_listing", ""), + current_time=getattr(self, "_runtime_current_time", None), + ) + + def _refresh_system_prompt(self) -> str: + system_prompt = self._build_current_system_prompt() + agent_loop = getattr(self, "_agent_loop", None) + if agent_loop is not None: + agent_loop.set_provider(self._provider_manager, system_prompt=system_prompt) + return system_prompt + def refresh_skills(self) -> None: """Rediscover skills and refresh enabled/disabled skill state.""" from iac_code.skills.bundled import init_bundled_skills @@ -302,7 +340,7 @@ def refresh_skills(self) -> None: continue self.command_registry.register(cmd) - memory_content = getattr(self, "_memory_content", "") + memory_context = self._refresh_memory_context() self.tool_registry.register( SkillTool( command_registry=self.command_registry, @@ -311,7 +349,11 @@ def refresh_skills(self) -> None: cwd=cwd, provider_manager=self._provider_manager, tool_registry=self.tool_registry, - system_prompt=build_system_prompt(cwd=cwd, memory_content=memory_content), + system_prompt=build_system_prompt( + cwd=cwd, + memory_context=memory_context, + current_time=self._runtime_current_time, + ), ) ) @@ -320,14 +362,7 @@ def refresh_skills(self) -> None: if hasattr(self, "_agent_loop"): self._agent_loop.set_auto_trigger_skills(skill_commands) - self._agent_loop.set_provider( - self._provider_manager, - system_prompt=build_system_prompt( - cwd=cwd, - memory_content=memory_content, - skill_listing=self._skill_listing, - ), - ) + self._agent_loop.set_provider(self._provider_manager, system_prompt=self._build_current_system_prompt()) async def run(self, initial_prompt: str | None = None) -> None: """Run the REPL until the user exits. @@ -696,6 +731,8 @@ def _open_history_search(self) -> bool: def _history_search_messages(self) -> list[dict[str, str]]: """Build searchable user-history rows from prompt history and conversation context.""" + from iac_code.agent.message import RECALLED_MEMORY_MARKER, is_recalled_memory_message + entries: list[str] = [] seen: set[str] = set() @@ -703,6 +740,8 @@ def add_text(text: str) -> None: cleaned = text.strip() if not cleaned or cleaned in seen: return + if RECALLED_MEMORY_MARKER in cleaned: + return seen.add(cleaned) entries.append(cleaned) @@ -721,6 +760,8 @@ def add_text(text: str) -> None: for msg in context_messages: if getattr(msg, "role", None) != "user": continue + if is_recalled_memory_message(msg): + continue get_text = getattr(msg, "get_text", None) if callable(get_text): add_text(get_text()) @@ -1176,14 +1217,7 @@ def _reinitialize_provider(self, new_model: str) -> None: provider_key_override=self._provider_key_override, base_url_override=self._base_url_override, ) - memory_content = "" - if hasattr(self, "_memory_manager") and self._memory_manager: - memory_content = self._memory_manager.get_prompt_content() - skill_listing = getattr(self, "_skill_listing", "") - new_system_prompt = build_system_prompt( - cwd=os.getcwd(), memory_content=memory_content, skill_listing=skill_listing - ) - self._agent_loop.set_provider(self._provider_manager, system_prompt=new_system_prompt) + self._refresh_system_prompt() # ------------------------------------------------------------------ # Helpers @@ -1360,6 +1394,9 @@ def get_status_snapshot(self) -> dict[str, Any]: "turn_count": self._count_user_turns(messages), "max_turns": self._agent_loop.max_turns, "context_usage": self._agent_loop.get_context_usage(), + "memory_recall": self._agent_loop.get_memory_recall_stats() + if hasattr(self._agent_loop, "get_memory_recall_stats") + else {}, } def _status_provider_display(self) -> str: @@ -1397,12 +1434,14 @@ def _status_region() -> str: @staticmethod def _count_user_turns(messages: list) -> int: - from iac_code.agent.message import ToolResultBlock + from iac_code.agent.message import ToolResultBlock, is_recalled_memory_message turns = 0 for message in messages: if getattr(message, "role", None) != "user": continue + if is_recalled_memory_message(message): + continue content = getattr(message, "content", "") if isinstance(content, list) and any(isinstance(block, ToolResultBlock) for block in content): continue @@ -1525,14 +1564,16 @@ def _write_last_prompt_meta(self) -> None: @staticmethod def _extract_last_user_text(messages: list) -> str: """Walk messages from newest to oldest, return first plain user text.""" - from iac_code.agent.message import TextBlock + from iac_code.agent.message import RECALLED_MEMORY_MARKER, TextBlock, is_recalled_memory_message for msg in reversed(messages): if msg.role != "user": continue + if is_recalled_memory_message(msg): + continue content = msg.content if isinstance(content, str): - if content.strip(): + if content.strip() and RECALLED_MEMORY_MARKER not in content: return content continue if isinstance(content, list): diff --git a/src/iac_code/ui/suggestions/command_provider.py b/src/iac_code/ui/suggestions/command_provider.py index 1aaf084..5ab2fe5 100644 --- a/src/iac_code/ui/suggestions/command_provider.py +++ b/src/iac_code/ui/suggestions/command_provider.py @@ -8,6 +8,8 @@ from iac_code.i18n import _ from iac_code.ui.suggestions.types import CompletionToken, SuggestionItem, SuggestionProvider +MEMORY_FOLDER_COMMAND = "memory-folder" + class CommandProvider(SuggestionProvider): """Provides slash-command suggestions from a CommandRegistry.""" @@ -25,6 +27,8 @@ def provide(self, token: CompletionToken) -> list[SuggestionItem]: if self._is_memory_argument_query(query): return self._memory_argument_suggestions(query) + if query.startswith("memory") and len(query) > len("memory") and query[len("memory")].isspace(): + return [] matches = self._registry.fuzzy_search(query) @@ -51,10 +55,14 @@ def provide(self, token: CompletionToken) -> list[SuggestionItem]: @staticmethod def _is_memory_argument_query(query: str) -> bool: - return query.startswith("memory") and len(query) > len("memory") and query[len("memory")].isspace() + return ( + query.startswith(MEMORY_FOLDER_COMMAND) + and len(query) > len(MEMORY_FOLDER_COMMAND) + and query[len(MEMORY_FOLDER_COMMAND)].isspace() + ) def _memory_argument_suggestions(self, query: str) -> list[SuggestionItem]: - arg_text = query[len("memory") :].lstrip() + arg_text = query[len(MEMORY_FOLDER_COMMAND) :].lstrip() has_trailing_space = bool(arg_text) and arg_text[-1].isspace() parts = arg_text.split() @@ -64,7 +72,7 @@ def _memory_argument_suggestions(self, query: str) -> list[SuggestionItem]: action = parts[0].lower() if action == "delete" and (has_trailing_space or len(parts) > 1): prefix = parts[1] if len(parts) > 1 else "" - return self._memory_name_suggestions(prefix, command_prefix="/memory delete ") + return self._memory_name_suggestions(prefix, command_prefix="/memory-folder delete ") if action == "search" and has_trailing_space: return [] @@ -76,11 +84,11 @@ def _memory_argument_suggestions(self, query: str) -> list[SuggestionItem]: def _memory_first_argument_suggestions(self, prefix: str) -> list[SuggestionItem]: suggestions = [ - self._memory_action_item("search", _("Search saved memories"), "/memory search ", prefix), - self._memory_action_item("delete", _("Delete a saved memory"), "/memory delete ", prefix), - self._memory_action_item("help", _("Show memory command help"), "/memory help", prefix), + self._memory_action_item("search", _("Search saved memories"), "/memory-folder search ", prefix), + self._memory_action_item("delete", _("Delete a saved memory"), "/memory-folder delete ", prefix), + self._memory_action_item("help", _("Show memory command help"), "/memory-folder help", prefix), ] - suggestions.extend(self._memory_name_suggestions(prefix, command_prefix="/memory ")) + suggestions.extend(self._memory_name_suggestions(prefix, command_prefix="/memory-folder ")) return [item for item in suggestions if item is not None] def _memory_action_item( diff --git a/tests/acp/test_scenarios.py b/tests/acp/test_scenarios.py index b81716a..fc9cba6 100644 --- a/tests/acp/test_scenarios.py +++ b/tests/acp/test_scenarios.py @@ -49,7 +49,14 @@ from iac_code.acp.session import ACPSession, _history_message_to_updates from iac_code.acp.state import ToolCallState, TurnState from iac_code.acp.tools import ACPTerminalBashTool -from iac_code.agent.message import Message, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock +from iac_code.agent.message import ( + Message, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + create_recalled_memory_message, +) from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult from iac_code.types.stream_events import ( MessageEndEvent, @@ -265,6 +272,9 @@ def test_a4_history_message_to_updates_all_types() -> None: assert len(updates) == 1 assert updates[0].session_update == "user_message_chunk" + recalled_memory = create_recalled_memory_message("# Recalled Memory\nPrefer ROS YAML.", ["ros-yaml.md"]) + assert _history_message_to_updates(recalled_memory) == [] + asst_text = Message(role="assistant", content=[TextBlock(text="reply")]) updates = _history_message_to_updates(asst_text) assert len(updates) == 1 @@ -1363,10 +1373,12 @@ async def test_pushed_commands_match_registry(monkeypatch: pytest.MonkeyPatch) - pushed_names = {cmd.name for cmd in cmd_updates[0].available_commands} from iac_code.acp.slash_registry import ACP_SUPPORTED_COMMANDS + from iac_code.commands import create_default_registry - expected_names = set(ACP_SUPPORTED_COMMANDS) + expected_names = {cmd.name for cmd in create_default_registry().get_all() if cmd.name in ACP_SUPPORTED_COMMANDS} assert pushed_names == expected_names, f"Mismatch: pushed={pushed_names}, expected={expected_names}" + assert "memory-folder" not in pushed_names @pytest.mark.asyncio diff --git a/tests/acp/test_server_coverage.py b/tests/acp/test_server_coverage.py index ea67f8a..d3e4214 100644 --- a/tests/acp/test_server_coverage.py +++ b/tests/acp/test_server_coverage.py @@ -29,6 +29,7 @@ SESSION_IDLE_TIMEOUT, ACPServer, _convert_mcp_servers, + _runtime_command_memory_manager, ) from iac_code.acp.session import ACPSession from iac_code.agent.message import Message @@ -77,6 +78,16 @@ def __init__(self, session_id: str = "test-session") -> None: self.tool_registry = None +def test_runtime_command_memory_manager_prefers_legacy_manager() -> None: + legacy = object() + project = object() + + runtime = type("Runtime", (), {"legacy_memory_manager": legacy, "memory_manager": project})() + + assert _runtime_command_memory_manager(runtime) is legacy + assert _runtime_command_memory_manager(type("Runtime", (), {"memory_manager": project})()) is project + + def _patch_server(monkeypatch, session_id: str = "test-session") -> None: monkeypatch.setattr("iac_code.acp.server.load_saved_model", lambda: "fake-model") monkeypatch.setattr( diff --git a/tests/acp/test_sessions.py b/tests/acp/test_sessions.py index 3ac12b8..92cc7a1 100644 --- a/tests/acp/test_sessions.py +++ b/tests/acp/test_sessions.py @@ -198,11 +198,11 @@ def list_memories(self): @pytest.mark.asyncio -async def test_acp_session_slash_memory_uses_session_memory_manager() -> None: +async def test_acp_session_slash_memory_folder_uses_session_memory_manager() -> None: conn = _RecordingFakeConn() session = ACPSession("s-memory", _RecordingFakeLoop(), conn, memory_manager=_SessionMemoryManager()) - response = await session.prompt([acp.schema.TextContentBlock(type="text", text="/memory")]) + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="/memory-folder")]) assert response.stop_reason == "end_turn" assert conn.updates[0][0] == "s-memory" diff --git a/tests/acp/test_slash_registry.py b/tests/acp/test_slash_registry.py index 29e08d8..36d7d47 100644 --- a/tests/acp/test_slash_registry.py +++ b/tests/acp/test_slash_registry.py @@ -49,6 +49,7 @@ async def test_unsupported_command_returns_rejection(registry: ACPSlashRegistry) result = await registry.execute("/help", agent_loop=None) assert "not supported" in result or "不支持" in result assert "/clear" in result or "clear" in result + assert "/memory-folder" not in result @pytest.mark.asyncio @@ -200,7 +201,7 @@ async def test_debug_invalid_arg(registry: ACPSlashRegistry) -> None: # --------------------------------------------------------------------------- -# execute — /memory +# execute — /memory-folder # --------------------------------------------------------------------------- @@ -241,7 +242,7 @@ def search(self, query): @pytest.mark.asyncio async def test_memory_without_manager_returns_unavailable(registry: ACPSlashRegistry) -> None: - result = await registry.execute("/memory", agent_loop=None) + result = await registry.execute("/memory-folder", agent_loop=None) assert result == "Memory manager is unavailable." @@ -249,10 +250,12 @@ async def test_memory_without_manager_returns_unavailable(registry: ACPSlashRegi async def test_memory_list_view_search_delete(registry: ACPSlashRegistry) -> None: memory_manager = _MemoryManager() - listed = await registry.execute("/memory", agent_loop=None, memory_manager=memory_manager) - viewed = await registry.execute("/memory user-role", agent_loop=None, memory_manager=memory_manager) - searched = await registry.execute("/memory search integration", agent_loop=None, memory_manager=memory_manager) - deleted = await registry.execute("/memory delete user-role", agent_loop=None, memory_manager=memory_manager) + listed = await registry.execute("/memory-folder", agent_loop=None, memory_manager=memory_manager) + viewed = await registry.execute("/memory-folder user-role", agent_loop=None, memory_manager=memory_manager) + searched = await registry.execute( + "/memory-folder search integration", agent_loop=None, memory_manager=memory_manager + ) + deleted = await registry.execute("/memory-folder delete user-role", agent_loop=None, memory_manager=memory_manager) assert "Saved memories:" in listed assert viewed == "[user] Role\n\nSenior engineer" @@ -265,15 +268,15 @@ async def test_memory_list_view_search_delete(registry: ACPSlashRegistry) -> Non async def test_memory_help_missing_invalid_name_and_unknown_usage(registry: ACPSlashRegistry) -> None: memory_manager = _MemoryManager() - helped = await registry.execute("/memory help", agent_loop=None, memory_manager=memory_manager) - missing = await registry.execute("/memory missing", agent_loop=None, memory_manager=memory_manager) - invalid = await registry.execute("/memory ../escape", agent_loop=None, memory_manager=memory_manager) - unknown = await registry.execute("/memory remove user-role", agent_loop=None, memory_manager=memory_manager) + helped = await registry.execute("/memory-folder help", agent_loop=None, memory_manager=memory_manager) + missing = await registry.execute("/memory-folder missing", agent_loop=None, memory_manager=memory_manager) + invalid = await registry.execute("/memory-folder ../escape", agent_loop=None, memory_manager=memory_manager) + unknown = await registry.execute("/memory-folder remove user-role", agent_loop=None, memory_manager=memory_manager) - assert helped == "Usage: /memory [|search |delete |help]" + assert helped == "Usage: /memory-folder [|search |delete |help]" assert missing == "Memory 'missing' not found." assert invalid == "Invalid memory name: '../escape'" - assert unknown == "Usage: /memory [|search |delete |help]" + assert unknown == "Usage: /memory-folder [|search |delete |help]" # --------------------------------------------------------------------------- diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index 345c621..6a89176 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -1,3 +1,4 @@ +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -34,6 +35,66 @@ def mock_registry(): return r +def test_init_syncs_recalled_memory_from_resume_messages(mock_provider, mock_registry): + from iac_code.agent.message import create_recalled_memory_message + + class FakeRecallService: + def __init__(self): + self.surfaced: set[str] = set() + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + def get_stats_snapshot(self): + return {"last_status": "skipped"} + + recall_service = FakeRecallService() + AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + resume_messages=[create_recalled_memory_message("# Recalled Memory\nUse YAML", ["ros-yaml.md"])], + memory_recall_service=recall_service, + ) + + assert recall_service.surfaced == {"ros-yaml.md"} + + +def test_replace_session_syncs_recalled_memory_from_loaded_messages(mock_provider, mock_registry): + from iac_code.agent.message import create_recalled_memory_message + + class FakeRecallService: + def __init__(self): + self.reset_called = False + self.surfaced: set[str] = set() + + def reset_stats(self): + self.reset_called = True + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + def get_stats_snapshot(self): + return {"last_status": "skipped"} + + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="old-session", + memory_recall_service=recall_service, + ) + + loop.replace_session( + "new-session", + resume_messages=[create_recalled_memory_message("# Recalled Memory\nUse YAML", ["ros-yaml.md"])], + ) + + assert recall_service.reset_called is True + assert recall_service.surfaced == {"ros-yaml.md"} + + class TestAgentLoopInit: def test_init(self, mock_provider, mock_registry): loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) @@ -151,6 +212,563 @@ async def fake_stream(messages, system, tools=None, max_tokens=8192): result = await loop.run("Hi") assert result == "Hello!" + async def test_memory_recall_is_hidden_context_message(self, mock_provider, mock_registry): + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.queries: list[str] = [] + self.surfaced: set[str] = set() + self.replaced: list[set[str]] = [] + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + self.queries.append(user_input) + + async def recall(): + return MemoryRecallResult( + content="# Recalled Memory\nFreeze on 2026-06-15", + selected_files=["topic.md"], + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + def replace_surfaced_files(self, filenames): + self.replaced.append(set(filenames)) + + class FakeSessionStorage: + def __init__(self): + self.messages = [] + + def append(self, cwd, session_id, message, git_branch=None): + self.messages.append(message) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + storage = FakeSessionStorage() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + session_storage=storage, + memory_recall_service=recall_service, + ) + + events = [event async for event in loop.run_streaming("what is the deadline?")] + + assert any(isinstance(event, MessageEndEvent) for event in events) + assert recall_service.queries == ["what is the deadline?"] + assert captured_messages[0][-1].role == "user" + assert "Relevant persistent memories recalled for this conversation" in captured_messages[0][-1].content + assert "Freeze on 2026-06-15" in captured_messages[0][-1].content + assert recall_service.surfaced == {"topic.md"} + assert any(message.metadata.get("type") == "recalled_memory" for message in storage.messages) + assert any("Freeze on 2026-06-15" in str(message.content) for message in storage.messages) + assert not hasattr(loop, "_current_recalled_memory_content") or loop._current_recalled_memory_content == "" + + async def test_recalled_memory_persists_and_suppresses_duplicate_in_future_turn(self, mock_provider, mock_registry): + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id=f"m{len(captured_messages)}") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.queries: list[str] = [] + self.surfaced: set[str] = set() + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + if "ros-yaml.md" in self.surfaced: + return None + self.queries.append(user_input) + + async def recall(): + return MemoryRecallResult( + content="# Recalled Memory\nUse YAML for ROS templates", + selected_files=["ros-yaml.md"], + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + await loop.run("what format should I use?") + await loop.run("what format should I use?") + + assert recall_service.queries == ["what format should I use?"] + assert "Use YAML for ROS templates" in str(captured_messages[0]) + assert "Use YAML for ROS templates" in str(captured_messages[1]) + + async def test_manual_compaction_resyncs_recalled_memory_suppression(self, mock_provider, mock_registry): + class FakeRecallService: + def __init__(self): + self.surfaced: set[str] = set() + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + def get_stats_snapshot(self): + return {"last_status": "skipped"} + + async def fake_complete(messages, system): + return SimpleNamespace(text="summary", usage=Usage(input_tokens=1, output_tokens=1)) + + recall_service = FakeRecallService() + mock_provider.complete = fake_complete + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + loop.context_manager = MagicMock() + loop.context_manager.get_messages.return_value = [SimpleNamespace(role="user")] + loop.context_manager.build_compaction_prompt.return_value = "compact me" + loop.context_manager.apply_compaction.return_value = (1200, 400) + loop.context_manager.get_surfaced_memory_files.return_value = {"recent.md"} + + result = await loop.compact() + + assert result.status == "success" + assert recall_service.surfaced == {"recent.md"} + + async def test_memory_recall_usage_is_recorded_in_session_usage(self, mock_provider, mock_registry, tmp_path): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage(input_tokens=10, output_tokens=5)) + + class FakeRecallService: + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + async def recall(): + return MemoryRecallResult( + content="", + selected_files=[], + usage=Usage(input_tokens=4, output_tokens=1, cache_read_input_tokens=2), + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + from iac_code.services.session_usage import SessionUsageStore + + mock_provider.stream = fake_stream + store = SessionUsageStore(projects_dir=tmp_path) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="recall-usage-session", + cwd="/tmp/status-project", + session_usage_store=store, + memory_recall_service=FakeRecallService(), + ) + + await loop.run("Hi") + + totals = loop.get_session_usage() + assert totals.input_tokens == 14 + assert totals.output_tokens == 6 + assert totals.cache_read_input_tokens == 2 + assert totals.recorded_events == 2 + assert store.load("/tmp/status-project", "recall-usage-session").total_tokens == 20 + + async def test_slow_memory_prefetch_does_not_block_provider_call_and_appends_late( + self, mock_provider, mock_registry + ): + recall_can_finish = asyncio.Event() + + class FakeRecallService: + def __init__(self): + self.cancelled = False + self.surfaced: list[str] = [] + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + async def recall(): + await recall_can_finish.wait() + return MemoryRecallResult(content="# Recalled Memory\nlate", selected_files=["late.md"]) + + return MemoryRecallPrefetch( + asyncio.create_task(recall()), + on_cancel=lambda: setattr(self, "cancelled", True), + ) + + def get_stats_snapshot(self): + return {"last_status": "cancelled"} + + def mark_files_surfaced(self, filenames): + self.surfaced.extend(filenames) + + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + events = [event async for event in loop.run_streaming("fast answer")] + + assert any(isinstance(event, MessageEndEvent) for event in events) + assert all("# Recalled Memory" not in str(message.content) for message in captured_messages[0]) + assert recall_service.cancelled is False + + recall_can_finish.set() + for _ in range(5): + await asyncio.sleep(0) + if recall_service.surfaced: + break + + assert any("# Recalled Memory\nlate" in str(message.content) for message in loop.context_manager.get_messages()) + assert recall_service.surfaced == ["late.md"] + + async def test_late_memory_recall_is_persisted_for_matching_next_turn(self, mock_provider, mock_registry): + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id="m1") + await asyncio.sleep(0.02) + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.queries: list[str] = [] + self.surfaced: set[str] = set() + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + if "ros-yaml.md" in self.surfaced: + return None + self.queries.append(user_input) + + async def recall(): + await asyncio.sleep(0.01) + return MemoryRecallResult( + content="# Recalled Memory\nUse YAML for ROS templates", + selected_files=["ros-yaml.md"], + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + await loop.run("what format should I use?") + await loop.run("what format should I use?") + await loop.run("what format should I use?") + + assert recall_service.queries == ["what format should I use?"] + assert "# Recalled Memory" not in str(captured_messages[0]) + assert "Use YAML for ROS templates" in str(captured_messages[1]) + assert "Use YAML for ROS templates" in str(captured_messages[2]) + assert recall_service.surfaced == {"ros-yaml.md"} + + async def test_late_memory_recall_is_persisted_after_different_next_turn(self, mock_provider, mock_registry): + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id="m1") + await asyncio.sleep(0.02) + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.queries: list[str] = [] + self.surfaced: set[str] = set() + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + if user_input == "different question" or "ros-yaml.md" in self.surfaced: + return None + self.queries.append(user_input) + + async def recall(): + await asyncio.sleep(0.01) + return MemoryRecallResult( + content="# Recalled Memory\nUse YAML for ROS templates", + selected_files=["ros-yaml.md"], + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + await loop.run("what format should I use?") + await loop.run("different question") + await loop.run("what format should I use?") + + assert recall_service.queries == ["what format should I use?"] + assert "Use YAML for ROS templates" in str(captured_messages[1]) + assert "Use YAML for ROS templates" in str(captured_messages[2]) + + async def test_previous_turn_recall_completing_during_next_turn_is_persisted(self, mock_provider, mock_registry): + first_recall_can_finish = asyncio.Event() + second_stream_started = asyncio.Event() + second_stream_can_finish = asyncio.Event() + captured_messages = [] + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_messages.append(messages) + yield MessageStartEvent(message_id="m1") + if len(captured_messages) == 2: + second_stream_started.set() + await second_stream_can_finish.wait() + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.queries: list[str] = [] + self.surfaced: set[str] = set() + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + self.queries.append(user_input) + + async def recall(): + if user_input == "first": + await first_recall_can_finish.wait() + return MemoryRecallResult( + content="# Recalled Memory\nUse YAML for ROS templates", + selected_files=["ros-yaml.md"], + ) + return MemoryRecallResult(content="", selected_files=[]) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + def replace_surfaced_files(self, filenames): + self.surfaced = set(filenames) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + await loop.run("first") + second_run = asyncio.create_task(loop.run("second")) + await second_stream_started.wait() + first_recall_can_finish.set() + for _ in range(5): + await asyncio.sleep(0) + if recall_service.surfaced: + break + assert recall_service.surfaced == set() + + second_stream_can_finish.set() + await second_run + await loop.run("third") + + assert recall_service.surfaced == {"ros-yaml.md"} + assert "Use YAML for ROS templates" not in str(captured_messages[1]) + assert "Use YAML for ROS templates" in str(captured_messages[2]) + + async def test_cancelled_turn_discards_pending_memory_prefetch(self, mock_provider, mock_registry): + recall_can_finish = asyncio.Event() + stream_started = asyncio.Event() + stream_can_finish = asyncio.Event() + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + stream_started.set() + await stream_can_finish.wait() + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + class FakeRecallService: + def __init__(self): + self.cancelled = False + self.surfaced: list[str] = [] + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + async def recall(): + await recall_can_finish.wait() + return MemoryRecallResult( + content="# Recalled Memory\nDiscard cancelled turn context", + selected_files=["cancelled.md"], + ) + + return MemoryRecallPrefetch( + asyncio.create_task(recall()), + on_cancel=lambda: setattr(self, "cancelled", True), + ) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_surfaced(self, filenames): + self.surfaced.extend(filenames) + + mock_provider.stream = fake_stream + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="base system", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + + async def consume(): + async for _event in loop.run_streaming("cancel this turn"): + pass + + task = asyncio.create_task(consume()) + await stream_started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + recall_can_finish.set() + stream_can_finish.set() + for _ in range(5): + await asyncio.sleep(0) + + assert recall_service.cancelled is True + assert recall_service.surfaced == [] + assert not any( + getattr(message, "metadata", {}).get("type") == "recalled_memory" + for message in loop.context_manager.get_messages() + ) + + async def test_system_prompt_refresher_updates_each_provider_round_and_tools(self, mock_provider, mock_registry): + captured_systems: list[str] = [] + + class PromptAwareTool: + name = "read_file" + description = "Read" + input_schema = {} + + def __init__(self): + self.prompts: list[str] = [] + + def set_system_prompt(self, system_prompt: str) -> None: + self.prompts.append(system_prompt) + + tool = PromptAwareTool() + + def refresh_prompt(): + return "fresh-1" if not captured_systems else "fresh-2" + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + captured_systems.append(system) + if len(captured_systems) == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="toolu_1", name="read_file") + yield ToolUseEndEvent(tool_use_id="toolu_1", name="read_file", input={"path": "a.txt"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + yield MessageStartEvent(message_id="m2") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [tool] + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="stale", + tool_registry=mock_registry, + system_prompt_refresher=refresh_prompt, + ) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace(content="processed result") + loop._tool_executor.execute_batch = AsyncMock(return_value=[ToolResult(content="raw result", is_error=False)]) + + await loop.run("Hi") + + assert captured_systems == ["fresh-1", "fresh-2"] + assert tool.prompts[-1] == "fresh-2" + assert loop.context_manager.system_prompt == "fresh-2" + async def test_records_non_zero_message_end_usage(self, mock_provider, mock_registry, tmp_path): async def fake_stream(messages, system, tools=None, max_tokens=8192): yield MessageStartEvent(message_id="m1") @@ -390,6 +1008,296 @@ async def test_replace_session_reloads_usage_totals(self, mock_provider, mock_re assert loop.get_session_usage().output_tokens == 8 assert loop.get_session_usage().total_tokens == 15 + async def test_replace_session_resets_memory_recall_stats(self, mock_provider, mock_registry): + class FakeRecallService: + def __init__(self): + self.reset_called = False + + def reset_stats(self): + self.reset_called = True + + def get_stats_snapshot(self): + return {"last_status": "success", "total_side_queries": 1} + + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="old-session", + memory_recall_service=recall_service, + ) + + loop.replace_session("new-session", resume_messages=None) + + assert recall_service.reset_called is True + + async def test_replace_session_clears_last_provider_request_snapshot(self, mock_provider, mock_registry): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + mock_provider.stream = fake_stream + loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + + await loop.run("old prompt") + assert loop.get_last_provider_request_snapshot()["provider_messages"] + + loop.replace_session("new-session", resume_messages=None) + + assert loop.get_last_provider_request_snapshot() == {} + + async def test_reset_clears_last_provider_request_snapshot(self, mock_provider, mock_registry): + async def fake_stream(messages, system, tools=None, max_tokens=8192): + yield MessageStartEvent(message_id="m1") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + mock_provider.stream = fake_stream + loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + + await loop.run("old prompt") + assert loop.get_last_provider_request_snapshot()["provider_messages"] + + loop.reset() + + assert loop.get_last_provider_request_snapshot() == {} + + async def test_read_memory_tool_marks_file_as_read_for_recall_dedupe(self, mock_provider, mock_registry): + class FakeRecallService: + def __init__(self): + self.marked: list[str] = [] + + def get_stats_snapshot(self): + return {"last_status": "skipped"} + + def mark_files_read(self, files): + self.marked.extend(files) + + call_count = 0 + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + nonlocal call_count + call_count += 1 + if call_count == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="toolu_1", name="read_memory") + yield ToolUseEndEvent(tool_use_id="toolu_1", name="read_memory", input={"name": "project-deadline"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + return + + yield MessageStartEvent(message_id="m2") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + from iac_code.memory.memory_tools import ReadMemoryTool + + recall_service = FakeRecallService() + read_tool = ReadMemoryTool(MagicMock()) + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [read_tool] + mock_registry.get.return_value = read_tool + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace(content="processed result") + loop._tool_executor.execute_batch = AsyncMock( + return_value=[ToolResult(content="memory content", is_error=False)] + ) + + await loop.run("read remembered deadline") + + assert recall_service.marked == ["project-deadline.md"] + + async def test_read_memory_suppresses_completed_prefetch_injection(self, mock_provider, mock_registry): + recall_can_finish = asyncio.Event() + captured_messages = [] + + class FakeRecallService: + def __init__(self): + self.read_files: set[str] = set() + + def start_prefetch(self, user_input): + from iac_code.memory.recall import MemoryRecallPrefetch, MemoryRecallResult + + async def recall(): + await recall_can_finish.wait() + return MemoryRecallResult( + content="# Recalled Memory\nFreeze", + selected_files=["project-deadline.md"], + ) + + return MemoryRecallPrefetch(asyncio.create_task(recall())) + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def mark_files_read(self, files): + self.read_files.update(files) + + def get_suppressed_files(self): + return set(self.read_files) + + class FakeSessionStorage: + def __init__(self): + self.messages = [] + + def append(self, cwd, session_id, message, git_branch=None): + self.messages.append(message) + + call_count = 0 + + async def fake_stream(messages, system, tools=None, max_tokens=8192): + nonlocal call_count + call_count += 1 + captured_messages.append(messages) + if call_count == 1: + yield MessageStartEvent(message_id="m1") + yield ToolUseStartEvent(tool_use_id="toolu_1", name="read_memory") + yield ToolUseEndEvent(tool_use_id="toolu_1", name="read_memory", input={"name": "project-deadline"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + recall_can_finish.set() + return + + yield MessageStartEvent(message_id="m2") + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + from iac_code.memory.memory_tools import ReadMemoryTool + + recall_service = FakeRecallService() + storage = FakeSessionStorage() + read_tool = ReadMemoryTool(MagicMock()) + mock_provider.stream = fake_stream + mock_registry.list_tools.return_value = [read_tool] + mock_registry.get.return_value = read_tool + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=storage, + memory_recall_service=recall_service, + ) + loop._result_storage = MagicMock() + loop._result_storage.process.return_value = SimpleNamespace(content="processed result") + loop._tool_executor.execute_batch = AsyncMock( + return_value=[ToolResult(content="memory content", is_error=False)] + ) + + await loop.run("read remembered deadline") + + assert call_count == 2 + assert recall_service.read_files == {"project-deadline.md"} + assert "# Recalled Memory" not in str(captured_messages[1]) + assert "Freeze" not in str(captured_messages[1]) + assert not any(message.metadata.get("type") == "recalled_memory" for message in storage.messages) + + async def test_recalled_memory_injection_keeps_unsuppressed_files(self, mock_provider, mock_registry): + from iac_code.agent.message import get_recalled_memory_files + + class FakeRecallService: + def __init__(self): + self.surfaced: list[str] = [] + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def get_suppressed_files(self): + return {"old.md"} + + def mark_files_surfaced(self, filenames): + self.surfaced.extend(filenames) + + class FakeSessionStorage: + def __init__(self): + self.messages = [] + + def append(self, cwd, session_id, message, git_branch=None): + self.messages.append(message) + + storage = FakeSessionStorage() + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=storage, + memory_recall_service=recall_service, + ) + result = SimpleNamespace( + content=( + "# Recalled Memory\n\n" + "## old.md\n[project] old topic\n\nold body\n\n" + "## new.md\n[project] new topic\n\nnew body" + ), + selected_files=["old.md", "new.md"], + ) + + assert loop._inject_recalled_memory_result(result) is True + + [message] = storage.messages + assert get_recalled_memory_files(message) == ["new.md"] + assert "new body" in str(message.content) + assert "old body" not in str(message.content) + assert recall_service.surfaced == ["new.md"] + + async def test_compacted_out_recalled_memory_remains_suppressed(self, mock_provider, mock_registry): + from iac_code.agent.message import get_recalled_memory_files + + class FakeRecallService: + def __init__(self): + self.surfaced: set[str] = set() + + def get_stats_snapshot(self): + return {"last_status": "success"} + + def replace_surfaced_files(self, filenames): + self.surfaced = set(filenames) + + def get_suppressed_files(self): + return set(self.surfaced) + + def mark_files_surfaced(self, filenames): + self.surfaced.update(filenames) + + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) + first_result = SimpleNamespace( + content="# Recalled Memory\n\n## old.md\n[project] old topic\n\nold body", + selected_files=["old.md"], + ) + second_result = SimpleNamespace( + content="# Recalled Memory\n\n## old.md\n[project] old topic\n\nold body again", + selected_files=["old.md"], + ) + + assert loop._inject_recalled_memory_result(first_result) is True + for i in range(6): + loop.context_manager.add_user_message(f"User message {i}") + loop.context_manager.add_assistant_message(f"Assistant response {i}") + + loop.context_manager.apply_compaction("Summary after old memory") + loop._sync_recall_suppression_from_context() + + assert recall_service.surfaced == {"old.md"} + assert loop._inject_recalled_memory_result(second_result) is False + recalled_messages = [ + message + for message in loop.context_manager.get_messages() + if get_recalled_memory_files(message) == ["old.md"] + ] + assert recalled_messages == [] + async def test_run_streaming_executes_tools_and_applies_extensions(self, mock_provider, mock_registry): call_count = 0 @@ -804,6 +1712,35 @@ async def test_auto_compact_success(self, mock_provider, mock_registry): assert event.original_tokens == 1200 assert event.compacted_tokens == 400 + async def test_auto_compact_persists_compacted_session(self, mock_provider, mock_registry): + from iac_code.agent.message import Message + + mock_provider.complete = AsyncMock(return_value=SimpleNamespace(text="summary")) + session_storage = MagicMock() + compacted_messages = [Message(role="user", content="[Conversation Summary]\nsummary")] + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="auto-compact-session", + cwd="/tmp/status-project", + session_storage=session_storage, + ) + loop._current_git_branch = "main" + loop.context_manager = MagicMock() + loop.context_manager.build_compaction_prompt.return_value = "compact me" + loop.context_manager.apply_compaction.return_value = (1200, 400) + loop.context_manager.get_messages.return_value = compacted_messages + + await loop._auto_compact() + + session_storage.save.assert_called_once_with( + "/tmp/status-project", + "auto-compact-session", + compacted_messages, + git_branch="main", + ) + async def test_auto_compact_records_response_usage(self, mock_provider, mock_registry, tmp_path): from iac_code.services.session_usage import SessionUsageStore @@ -837,11 +1774,33 @@ async def test_auto_compact_records_response_usage(self, mock_provider, mock_reg assert store.load("/tmp/status-project", "auto-compact-usage").total_tokens == 15 async def test_auto_compact_returns_none_without_prompt(self, mock_provider, mock_registry): - loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + session_storage = MagicMock() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + ) loop.context_manager = MagicMock() loop.context_manager.build_compaction_prompt.return_value = "" assert await loop._auto_compact() is None + session_storage.save.assert_not_called() + + async def test_auto_compact_does_not_persist_on_provider_error(self, mock_provider, mock_registry): + mock_provider.complete = AsyncMock(side_effect=RuntimeError("boom")) + session_storage = MagicMock() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + ) + loop.context_manager = MagicMock() + loop.context_manager.build_compaction_prompt.return_value = "compact me" + + assert await loop._auto_compact() is None + session_storage.save.assert_not_called() async def test_compact_returns_success_with_tokens(self, mock_provider, mock_registry): mock_provider.complete = AsyncMock(return_value=SimpleNamespace(text="summary")) @@ -856,6 +1815,36 @@ async def test_compact_returns_success_with_tokens(self, mock_provider, mock_reg assert result.status == "success" assert (result.original_tokens, result.compacted_tokens) == (900, 300) + async def test_compact_persists_compacted_session(self, mock_provider, mock_registry): + from iac_code.agent.message import Message + + mock_provider.complete = AsyncMock(return_value=SimpleNamespace(text="summary")) + session_storage = MagicMock() + compacted_messages = [Message(role="user", content="[Conversation Summary]\nsummary")] + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="manual-compact-session", + cwd="/tmp/status-project", + session_storage=session_storage, + ) + loop._current_git_branch = "dev" + loop.context_manager = MagicMock() + loop.context_manager.get_messages.return_value = compacted_messages + loop.context_manager.build_compaction_prompt.return_value = "compact me" + loop.context_manager.apply_compaction.return_value = (900, 300) + + result = await loop.compact() + + assert result.status == "success" + session_storage.save.assert_called_once_with( + "/tmp/status-project", + "manual-compact-session", + compacted_messages, + git_branch="dev", + ) + async def test_compact_records_response_usage(self, mock_provider, mock_registry, tmp_path): from iac_code.services.session_usage import SessionUsageStore @@ -890,16 +1879,29 @@ async def test_compact_records_response_usage(self, mock_provider, mock_registry assert store.load("/tmp/status-project", "manual-compact-usage").total_tokens == 19 async def test_compact_returns_empty_when_no_messages(self, mock_provider, mock_registry): - loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + session_storage = MagicMock() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + ) loop.context_manager = MagicMock() loop.context_manager.get_messages.return_value = [] result = await loop.compact() assert result.status == "empty" + session_storage.save.assert_not_called() async def test_compact_returns_too_short_when_all_in_preserve_window(self, mock_provider, mock_registry): - loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + session_storage = MagicMock() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + ) loop.context_manager = MagicMock() loop.context_manager.get_messages.return_value = [object()] loop.context_manager.build_compaction_prompt.return_value = "" @@ -909,10 +1911,17 @@ async def test_compact_returns_too_short_when_all_in_preserve_window(self, mock_ assert result.status == "too_short" assert result.preserve_recent_turns == 3 + session_storage.save.assert_not_called() async def test_compact_returns_failed_on_provider_error(self, mock_provider, mock_registry): mock_provider.complete = AsyncMock(side_effect=RuntimeError("boom")) - loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + session_storage = MagicMock() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + ) loop.context_manager = MagicMock() loop.context_manager.get_messages.return_value = [object()] loop.context_manager.build_compaction_prompt.return_value = "compact me" @@ -920,11 +1929,28 @@ async def test_compact_returns_failed_on_provider_error(self, mock_provider, moc result = await loop.compact() assert result.status == "failed" + session_storage.save.assert_not_called() class TestAgentLoopHelpers: def test_reset_and_get_context_usage_delegate(self, mock_provider, mock_registry): - loop = AgentLoop(provider_manager=mock_provider, system_prompt="test", tool_registry=mock_registry) + class FakeRecallService: + def __init__(self): + self.reset_called = False + + def reset_stats(self): + self.reset_called = True + + def get_stats_snapshot(self): + return {"last_status": "success"} + + recall_service = FakeRecallService() + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + memory_recall_service=recall_service, + ) loop.context_manager = MagicMock() loop.context_manager.get_usage.return_value = {"total_tokens": 10} loop._auto_loaded_skills.add("iac-aliyun") @@ -935,6 +1961,7 @@ def test_reset_and_get_context_usage_delegate(self, mock_provider, mock_registry loop.context_manager.reset.assert_called_once() assert loop._auto_loaded_skills == set() assert usage == {"total_tokens": 10} + assert recall_service.reset_called is True def test_replace_session_clears_auto_loaded_skills(self, mock_provider, mock_registry): from iac_code.agent.message import Message diff --git a/tests/agent/test_message_metadata.py b/tests/agent/test_message_metadata.py new file mode 100644 index 0000000..a789ef5 --- /dev/null +++ b/tests/agent/test_message_metadata.py @@ -0,0 +1,30 @@ +from iac_code.agent.message import ( + RECALLED_MEMORY_MARKER, + Message, + create_recalled_memory_message, + get_recalled_memory_files, + is_recalled_memory_message, +) + + +def test_recalled_memory_message_serializes_metadata(): + msg = create_recalled_memory_message( + "# Recalled Memory\nUse YAML for ROS templates", + ["ros-yaml.md"], + ) + + data = msg.to_dict() + loaded = Message.from_dict(data) + + assert loaded.role == "user" + assert RECALLED_MEMORY_MARKER in loaded.get_text() + assert is_recalled_memory_message(loaded) is True + assert get_recalled_memory_files(loaded) == ["ros-yaml.md"] + assert loaded.to_api_format() == {"role": "user", "content": loaded.content} + + +def test_non_memory_message_has_no_recalled_files(): + msg = Message(role="user", content="hello") + + assert is_recalled_memory_message(msg) is False + assert get_recalled_memory_files(msg) == [] diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index d376ffd..f8fd686 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -1,4 +1,5 @@ import tempfile +from datetime import datetime as real_datetime from pathlib import Path from unittest.mock import patch @@ -112,6 +113,91 @@ def test_memory_section_absent_when_empty(self): memory_lines = [line for line in lines if line.strip().startswith("# Memory")] assert len(memory_lines) == 0 + def test_explicit_memory_context_includes_instruction_index_and_mechanics(self): + from iac_code.memory.project_memory import MemoryContext + + context = MemoryContext( + instruction_memory_content="User instruction\nProject instruction", + memory_index_content="- [topic-a](topic-a.md) - Topic A", + memory_mechanics_content="Use read_memory and write_memory for topic files.", + ) + + prompt = build_system_prompt(cwd=_TMP, memory_context=context) + + assert "User instruction" in prompt + assert "Project instruction" in prompt + assert "topic-a.md" in prompt + assert "read_memory" in prompt + assert "Topic body should not be always injected" not in prompt + + def test_project_instructions_stop_at_git_worktree_root(self, tmp_path: Path): + parent = tmp_path / "repo" + worktree = parent / ".worktrees" / "feature" + cwd = worktree / "src" + cwd.mkdir(parents=True) + (parent / "AGENTS.md").write_text("parent instructions", encoding="utf-8") + (worktree / "AGENTS.md").write_text("worktree instructions", encoding="utf-8") + (worktree / ".git").write_text("gitdir: ../../.git/worktrees/feature\n", encoding="utf-8") + + prompt = build_system_prompt(cwd=str(cwd)) + + assert "worktree instructions" in prompt + assert "parent instructions" not in prompt + + def test_project_instructions_skipped_for_local_build(self, tmp_path: Path, monkeypatch): + monkeypatch.setattr("iac_code.__release_date__", "") + cwd = tmp_path / "repo" + cwd.mkdir() + (cwd / ".git").mkdir() + (cwd / "AGENTS.md").write_text("local build instructions", encoding="utf-8") + + prompt = build_system_prompt(cwd=str(cwd)) + + assert "local build instructions" not in prompt + assert "# Project Instructions" not in prompt + + def test_volatile_current_time_stays_out_of_static_cache_prefix(self, monkeypatch): + from iac_code.agent import system_prompt + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + + first_static, first_dynamic = split_by_dynamic_boundary(build_system_prompt(cwd=_TMP)) + second_static, second_dynamic = split_by_dynamic_boundary(build_system_prompt(cwd=_TMP)) + + # Current time is a volatile runtime fact, so it must not invalidate the + # static cache prefix even when build_system_prompt() is called directly. + assert first_static == second_static + assert "Current time:" not in first_static + assert "- Current time: 2026-06-05 10:01:00" in first_dynamic + assert "- Current time: 2026-06-05 10:02:00" in second_dynamic + + def test_current_time_override_keeps_full_prompt_stable_when_clock_changes(self, monkeypatch): + from iac_code.agent import system_prompt + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + + first = build_system_prompt(cwd=_TMP, current_time="2026-06-05 10:00:00") + second = build_system_prompt(cwd=_TMP, current_time="2026-06-05 10:00:00") + + assert first == second + assert "- Current time: 2026-06-05 10:00:00" in first + class TestSplitByDynamicBoundary: def test_splits_at_boundary(self): diff --git a/tests/cli/test_headless.py b/tests/cli/test_headless.py index e026487..bb3b852 100644 --- a/tests/cli/test_headless.py +++ b/tests/cli/test_headless.py @@ -674,10 +674,27 @@ def __init__(self, projects_dir=None): class FakeMemoryManager: def __init__(self, *, memory_dir): captured["memory_dir"] = memory_dir + captured.setdefault("memory_dirs", []).append(memory_dir) def get_prompt_content(self): return "memory prompt" + class FakeProjectMemoryRuntime: + def __init__(self, cwd): + captured["project_memory_cwd"] = cwd + self.memory_manager = FakeMemoryManager(memory_dir=str(fake_session_dir / "projects" / "fake" / "memory")) + + def build_memory_context(self): + context = SimpleNamespace(instruction_memory_content="memory prompt") + captured["memory_context"] = context + return context + + class FakeMemoryRecallService: + def __init__(self, *, memory_manager, provider_manager): + self.memory_manager = memory_manager + self.provider_manager = provider_manager + captured["memory_recall_service"] = self + class FakeTaskManager: pass @@ -741,6 +758,8 @@ def __init__(self, name="prompt", **kwargs): monkeypatch.setattr("iac_code.services.cloud_credentials.CloudCredentials", FakeCloudCredentials) monkeypatch.setattr("iac_code.services.session_storage.SessionStorage", FakeSessionStorage) monkeypatch.setattr("iac_code.memory.memory_manager.MemoryManager", FakeMemoryManager) + monkeypatch.setattr("iac_code.memory.project_memory.ProjectMemoryRuntime", FakeProjectMemoryRuntime) + monkeypatch.setattr("iac_code.memory.recall.MemoryRecallService", FakeMemoryRecallService) monkeypatch.setattr("iac_code.memory.memory_tools.ReadMemoryTool", FakeReadMemoryTool) monkeypatch.setattr("iac_code.memory.memory_tools.WriteMemoryTool", FakeWriteMemoryTool) monkeypatch.setattr("iac_code.tasks.task_state.TaskManager", FakeTaskManager) @@ -766,7 +785,7 @@ def __init__(self, name="prompt", **kwargs): monkeypatch.setattr("iac_code.skills.listing.build_skill_listing", lambda skill_commands: "skill listing") monkeypatch.setattr( "iac_code.agent.system_prompt.build_system_prompt", - lambda **kwargs: f"prompt:{kwargs.get('cwd')}:{kwargs.get('memory_content')}:{kwargs.get('skill_listing')}", + lambda **kwargs: f"prompt:{kwargs.get('cwd')}:{kwargs.get('memory_context')}:{kwargs.get('skill_listing')}", ) monkeypatch.setattr("os.getcwd", lambda: "/worktree") monkeypatch.setattr("uuid.uuid4", lambda: SimpleNamespace(__str__=lambda self: "12345678-aaaa")) @@ -801,11 +820,14 @@ def test_create_agent_loop_builds_expected_dependencies(monkeypatch): # default projects_dir from get_config_dir(), so we just assert the # storage was instantiated rather than checking a specific path. assert "projects_dir" in captured - assert captured["memory_dir"] == str(Path("/tmp/iac-config/memory")) + assert captured["project_memory_cwd"] == "/worktree" + assert str(Path("/tmp/iac-config/projects/fake/memory")) in captured["memory_dirs"] + assert str(Path("/tmp/iac-config/memory")) in captured["memory_dirs"] assert any(getattr(tool, "kind", "") == "agent" for tool in fake_registry.registered) assert any(getattr(tool, "kind", "") == "skill" for tool in fake_registry.registered) assert captured["agent_loop_kwargs"]["max_turns"] == 100 assert captured["agent_loop_kwargs"]["session_storage"] is not None + assert captured["agent_loop_kwargs"]["memory_recall_service"] is captured["memory_recall_service"] assert fake_command_registry.registered == [] diff --git a/tests/commands/test_memory.py b/tests/commands/test_memory.py index 00b3649..fb2b120 100644 --- a/tests/commands/test_memory.py +++ b/tests/commands/test_memory.py @@ -1,9 +1,16 @@ from __future__ import annotations +from datetime import datetime as real_datetime + import pytest -from iac_code.commands.memory import execute_memory_command, memory_command +from iac_code.agent import system_prompt +from iac_code.commands import create_default_registry +from iac_code.commands import memory as memory_module +from iac_code.commands.memory import execute_memory_command from iac_code.memory.memory_manager import MemoryManager +from iac_code.memory.project_memory import ProjectMemoryRuntime +from iac_code.ui.dialogs.memory_editor import MemoryEditResult @pytest.fixture @@ -19,6 +26,40 @@ def __init__(self, manager): self.repl = type("Repl", (), {"_memory_manager": manager})() +class _ContextWithLegacy: + def __init__(self, legacy_manager, project_manager): + self.repl = type( + "Repl", + (), + { + "_legacy_memory_manager": legacy_manager, + "_memory_manager": project_manager, + }, + )() + + +class _MemoryRuntimeContext: + def __init__(self, runtime): + self.repl = type("Repl", (), {"_memory_runtime": runtime})() + + +class _RefreshingMemoryRuntimeContext: + def __init__(self, runtime): + self.refreshed = False + + def refresh_system_prompt(): + self.refreshed = True + + self.repl = type( + "Repl", + (), + { + "_memory_runtime": runtime, + "_refresh_system_prompt": staticmethod(refresh_system_prompt), + }, + )() + + def test_execute_memory_command_lists_memories(manager): output = execute_memory_command(manager, []) assert "Saved memories:" in output @@ -53,7 +94,7 @@ def test_execute_memory_command_search_no_matches(manager): def test_execute_memory_command_search_without_query_shows_help(manager): output = execute_memory_command(manager, ["search"]) - assert "Usage: /memory" in output + assert "Usage: /memory-folder" in output def test_execute_memory_command_deletes_memory(manager): @@ -73,17 +114,306 @@ def test_execute_memory_command_invalid_name(manager): def test_execute_memory_command_help_and_unknown_multi_token(manager): - assert "Usage: /memory" in execute_memory_command(manager, ["help"]) - assert "Usage: /memory" in execute_memory_command(manager, ["remove", "user-role"]) + assert "Usage: /memory-folder" in execute_memory_command(manager, ["help"]) + assert "Usage: /memory-folder" in execute_memory_command(manager, ["remove", "user-role"]) @pytest.mark.asyncio -async def test_memory_command_uses_repl_memory_manager(manager): - output = await memory_command(context=_Context(manager), args=["user-role"]) +async def test_memory_folder_command_uses_repl_memory_manager(manager): + output = await memory_module.memory_folder_command(context=_Context(manager), args=["user-role"]) assert output == "[user] Role\n\nSenior cloud engineer" @pytest.mark.asyncio -async def test_memory_command_missing_context_manager(): - output = await memory_command(context=object(), args=[]) +async def test_memory_folder_command_prefers_legacy_memory_manager(tmp_path): + legacy = MemoryManager(memory_dir=str(tmp_path / "legacy")) + legacy.save("same-name", "Legacy content", memory_type="project", description="Legacy") + project = MemoryManager(memory_dir=str(tmp_path / "project")) + project.save("same-name", "Project content", memory_type="project", description="Project") + + output = await memory_module.memory_folder_command(context=_ContextWithLegacy(legacy, project), args=["same-name"]) + + assert output == "[project] Legacy\n\nLegacy content" + + +@pytest.mark.asyncio +async def test_memory_folder_command_missing_context_manager(): + output = await memory_module.memory_folder_command(context=object(), args=[]) assert output == "Memory manager is unavailable." + + +def test_default_registry_exposes_new_memory_and_hides_memory_folder(): + registry = create_default_registry() + + memory = registry.get("memory") + memory_folder = registry.get("memory-folder") + + assert memory is not None + assert memory.description == "Edit IAC-CODE memory files" + assert memory.hidden is False + assert memory.arg_hint is None + assert memory_folder is not None + assert memory_folder.hidden is True + assert memory_folder.arg_hint == "[|search |delete |help]" + assert "memory-folder" not in {cmd.name for cmd in registry.get_all()} + + +@pytest.mark.asyncio +async def test_memory_command_opens_project_iac_code_file(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + edited: list[object] = [] + monkeypatch.setattr( + memory_module, + "_select_memory_action", + lambda runtime, **kwargs: "project", + raising=False, + ) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: edited.append((path, title)) or MemoryEditResult("saved", "Project rules\n"), + raising=False, + ) + + output = await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) + + assert runtime.project_instruction_path.exists() + assert edited == [(runtime.project_instruction_path, "Project memory")] + assert runtime.project_instruction_path.read_text(encoding="utf-8") == "Project rules\n" + assert output == "Saved project memory: {}".format(runtime.project_instruction_path) + + +@pytest.mark.asyncio +async def test_memory_command_refreshes_full_system_prompt_after_open(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + context = _RefreshingMemoryRuntimeContext(runtime) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "project", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("saved", "Project rules\n"), + raising=False, + ) + + await memory_module.memory_command(context=context, args=[]) + + assert context.refreshed is True + + +@pytest.mark.asyncio +async def test_memory_command_fallback_refresh_reuses_runtime_current_time(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + captured: dict[str, str] = {} + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + class FakeAgentLoop: + def set_provider(self, provider_manager, *, system_prompt): + captured["system_prompt"] = system_prompt + + class FakeRepl: + _memory_runtime = runtime + _agent_loop = FakeAgentLoop() + _provider_manager = object() + _runtime_current_time = "2026-06-05 10:00:00" + _skill_listing = "" + + @staticmethod + def _refresh_memory_context(): + return runtime.build_memory_context() + + repl = type( + "Repl", + (FakeRepl,), + {}, + )() + context = type("Context", (), {"repl": repl})() + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "project", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("saved", "Project rules\n"), + raising=False, + ) + + await memory_module.memory_command(context=context, args=[]) + + assert "- Current time: 2026-06-05 10:00:00" in captured["system_prompt"] + + +@pytest.mark.asyncio +async def test_memory_command_opens_user_iac_code_file(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + edited: list[object] = [] + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "user", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: edited.append((path, title)) or MemoryEditResult("saved", "User rules\n"), + raising=False, + ) + + output = await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) + + assert runtime.user_instruction_path.exists() + assert edited == [(runtime.user_instruction_path, "User memory")] + assert runtime.user_instruction_path.read_text(encoding="utf-8") == "User rules\n" + assert output == "Saved user memory: {}".format(runtime.user_instruction_path) + + +@pytest.mark.asyncio +async def test_memory_command_does_not_refresh_when_editor_reports_unchanged(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + context = _RefreshingMemoryRuntimeContext(runtime) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "project", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("unchanged", ""), + raising=False, + ) + + output = await memory_module.memory_command(context=context, args=[]) + + assert context.refreshed is False + assert output == "No changes made to project memory: {}".format(runtime.project_instruction_path) + assert not runtime.project_instruction_path.exists() + + +@pytest.mark.asyncio +async def test_memory_command_unchanged_user_edit_does_not_create_empty_file(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + context = _RefreshingMemoryRuntimeContext(runtime) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "user", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("unchanged", ""), + raising=False, + ) + + output = await memory_module.memory_command(context=context, args=[]) + + assert context.refreshed is False + assert output == "No changes made to user memory: {}".format(runtime.user_instruction_path) + assert not runtime.user_instruction_path.exists() + + +@pytest.mark.asyncio +async def test_memory_command_cancelled_project_edit_does_not_create_empty_file(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "project", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("cancelled", ""), + raising=False, + ) + + output = await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) + + assert output is None + assert not runtime.project_instruction_path.exists() + + +@pytest.mark.asyncio +async def test_memory_command_cancelled_user_edit_does_not_create_empty_file(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: "user", raising=False) + monkeypatch.setattr( + memory_module, + "_edit_memory_file", + lambda path, title: MemoryEditResult("cancelled", ""), + raising=False, + ) + + output = await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) + + assert output is None + assert not runtime.user_instruction_path.exists() + + +@pytest.mark.asyncio +async def test_memory_command_opens_auto_memory_folder(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + opened: list[object] = [] + initial_actions: list[str | None] = [] + actions = iter(["folder", None]) + + def select_memory_action(runtime, **kwargs): + initial_actions.append(kwargs.get("initial_action")) + return next(actions) + + monkeypatch.setattr(memory_module, "_select_memory_action", select_memory_action, raising=False) + monkeypatch.setattr(memory_module, "_open_folder", lambda path: opened.append(path), raising=False) + + output = await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) + + assert runtime.auto_memory_dir.exists() + assert opened == [runtime.auto_memory_dir] + assert initial_actions == [None, "folder"] + assert output is None + + +@pytest.mark.asyncio +async def test_memory_command_cancel_returns_none(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + monkeypatch.setattr(memory_module, "_select_memory_action", lambda runtime, **kwargs: None, raising=False) + + assert await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) is None + + +@pytest.mark.asyncio +async def test_memory_command_toggle_auto_memory_persists_setting(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runtime = ProjectMemoryRuntime(str(project)) + + def select_and_toggle(runtime, *, auto_memory_enabled, on_toggle, initial_action=None): + assert auto_memory_enabled is True + assert initial_action is None + on_toggle(False) + return None + + monkeypatch.setattr(memory_module, "_select_memory_action", select_and_toggle, raising=False) + + assert await memory_module.memory_command(context=_MemoryRuntimeContext(runtime), args=[]) is None + assert memory_module.is_auto_memory_enabled() is False diff --git a/tests/commands/test_prompt.py b/tests/commands/test_prompt.py new file mode 100644 index 0000000..6bcf409 --- /dev/null +++ b/tests/commands/test_prompt.py @@ -0,0 +1,168 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from iac_code.commands import create_default_registry +from iac_code.commands import prompt as prompt_module +from iac_code.providers.base import ContentBlock, Message, ToolDefinition + + +class _FakeAgentLoop: + system_prompt = "# Stale\nold" + session_id = "session-123" + + def _get_provider_messages(self): + return [ + Message.user("hello from user"), + Message( + role="assistant", + content=[ + ContentBlock(type="text", text="assistant text"), + ContentBlock(type="tool_use", tool_use_id="toolu_1", name="read_file", input={"path": "a.py"}), + ], + ), + ] + + def _get_tool_definitions(self): + return [ + ToolDefinition( + name="read_file", + description="Read a file", + input_schema={"type": "object", "properties": {"path": {"type": "string"}}}, + ) + ] + + +class _FakeAgentLoopWithLastRequest(_FakeAgentLoop): + def _get_provider_messages(self): + return [Message.user("current runtime message")] + + def get_last_provider_request_snapshot(self): + return { + "system_prompt": "# Sent System\nactual sent system", + "provider_messages": [ + Message.user("actual user question"), + Message.user( + "\n" + "Relevant persistent memories recalled for this conversation:\n\n" + "# Recalled Memory\n" + "Prefer ROS YAML.\n" + "" + ), + ], + "tools": self._get_tool_definitions(), + } + + +def test_default_registry_hides_prompt_command(): + registry = create_default_registry() + + command = registry.get("prompt") + + assert command is not None + assert command.hidden is True + assert "prompt" not in {cmd.name for cmd in registry.get_all()} + assert "prompt" not in registry.get_completions("p") + + +def test_prompt_html_uses_tabs_without_memory_tab(): + html = prompt_module.render_prompt_html( + { + "metadata": {"session_id": "abc"}, + "system_prompt": "# Memory\nProject memory index", + "system_sections": [{"title": "Memory", "content": "# Memory\nProject memory index", "zone": "dynamic"}], + "provider_messages": [{"role": "user", "content": "hello"}], + "tools": [{"name": "read_file", "description": "Read", "input_schema": {"type": "object"}}], + } + ) + + assert 'role="tablist"' in html + assert 'data-tab-target="all"' in html + assert 'data-tab-target="system"' in html + assert 'data-tab-target="messages"' in html + assert 'data-tab-target="tools"' in html + assert 'data-tab-target="memory"' not in html + assert "

Memory

" not in html + assert "Prompt Assembly Order" in html + assert "1. System Prompt" in html + assert "2. Provider Messages" in html + assert "3. Tools" in html + assert "Project memory index" in html + + +def test_prompt_snapshot_prefers_last_provider_request_with_recalled_memory(): + repl = SimpleNamespace( + _agent_loop=_FakeAgentLoopWithLastRequest(), + get_status_snapshot=lambda: {"session_id": "session-123"}, + ) + + snapshot = prompt_module.build_prompt_snapshot(repl) + html = prompt_module.render_prompt_html(snapshot) + + assert snapshot["metadata"]["source"] == "Last main-model request" + assert "actual sent system" in html + assert "actual user question" in html + assert "Relevant persistent memories recalled for this conversation" in html + assert "Prefer ROS YAML." in html + assert "current runtime message" not in html + assert "recalled memory" in html + assert "provider-only" not in html + assert "hidden conversation" in html + + +@pytest.mark.asyncio +async def test_prompt_command_exports_html_and_opens(tmp_path, monkeypatch): + opened: list[object] = [] + monkeypatch.setattr(prompt_module, "_open_path", lambda path: opened.append(path)) + + repl = SimpleNamespace( + _agent_loop=_FakeAgentLoop(), + _memory_context=SimpleNamespace( + instruction_memory_content="Project instruction memory", + memory_index_content="ros-yaml.md - ROS YAML preference", + memory_mechanics_content="Use read_memory for full topic files.", + ), + _build_current_system_prompt=lambda: ( + "Identity preamble\n\n" + "# System Rules\n" + "Follow the rules.\n\n" + "--- DYNAMIC_BOUNDARY ---\n\n" + "# Environment\n" + "- Working directory: `/tmp/project`" + ), + get_status_snapshot=lambda: { + "session_id": "session-123", + "provider": "DashScope", + "model": "qwen3.7-max", + "cwd": "/tmp/project", + }, + ) + context = SimpleNamespace(repl=repl) + + result = await prompt_module.prompt_command(context=context, output_dir=tmp_path) + + assert result is not None + assert "Prompt exported and opened" in result + assert len(opened) == 1 + html_path = opened[0] + assert html_path.parent == tmp_path + assert html_path.suffix == ".html" + + html = html_path.read_text(encoding="utf-8") + assert "Prompt Snapshot" in html + assert "System Prompt" in html + assert "System Rules" in html + assert "Provider Messages" in html + assert "hello from user" in html + assert "assistant text" in html + assert "Tools" in html + assert "read_file" in html + assert "qwen3.7-max" in html + + +@pytest.mark.asyncio +async def test_prompt_command_requires_repl_context(): + result = await prompt_module.prompt_command(context=MagicMock(repl=None)) + + assert "REPL context" in result diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index 13e4e00..d6d2329 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -138,6 +138,156 @@ async def test_status_prints_no_recorded_usage_message() -> None: assert "No recorded API usage" in rendered +@pytest.mark.asyncio +async def test_status_hides_memory_recall_usage_outside_debug(monkeypatch) -> None: + monkeypatch.setattr("iac_code.utils.log.is_debug_enabled", lambda: False) + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "memory", + "resumed": False, + "provider": "dashscope", + "model": "qwen", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage(), + "turn_count": 1, + "max_turns": 100, + "context_usage": { + "total_tokens": 1000, + "context_window": 128000, + "usage_percent": 1.0, + }, + "memory_recall": { + "total_side_queries": 3, + "successful_side_queries": 2, + "failed_side_queries": 1, + "cancelled_side_queries": 0, + "last_status": "success", + "last_duration_ms": 412, + "last_selected_files": ["project-deadline.md"], + "last_side_query_status": "success", + "last_side_query_duration_ms": 411, + "last_side_query_selected_files": ["project-deadline.md"], + "total_usage": { + "input_tokens": 1234, + "output_tokens": 56, + "cache_read_input_tokens": 78, + "cache_creation_input_tokens": 9, + "total_tokens": 1290, + "recorded_events": 3, + "has_recorded_usage": True, + }, + "last_usage": { + "input_tokens": 321, + "output_tokens": 6, + "cache_read_input_tokens": 7, + "cache_creation_input_tokens": 0, + "total_tokens": 327, + "recorded_events": 1, + "has_recorded_usage": True, + }, + "last_prompt_preview": "User query:\ndeadline", + "last_response_preview": '{"files":["project-deadline.md"]}', + "last_prompt_chars": 20, + "last_response_chars": 32, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + assert "Memory Recall" not in rendered + assert "Side call usage" not in rendered + assert "Last usage" not in rendered + assert "Turns" in rendered + assert "User query:" not in rendered + assert '{"files":["project-deadline.md"]}' not in rendered + + +@pytest.mark.asyncio +async def test_status_prints_memory_recall_metrics_in_debug(monkeypatch) -> None: + monkeypatch.setattr("iac_code.utils.log.is_debug_enabled", lambda: True) + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "memory", + "resumed": False, + "provider": "dashscope", + "model": "qwen", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage(), + "turn_count": 1, + "max_turns": 100, + "context_usage": { + "total_tokens": 1000, + "context_window": 128000, + "usage_percent": 1.0, + }, + "memory_recall": { + "total_side_queries": 3, + "successful_side_queries": 2, + "failed_side_queries": 1, + "cancelled_side_queries": 1, + "total_selected_files": 4, + "last_duration_ms": 412, + "last_status": "skipped", + "last_selected_files": [], + "last_side_query_duration_ms": 411, + "last_side_query_status": "success", + "last_side_query_selected_files": ["project-deadline.md", "feedback-testing.md"], + "total_usage": { + "input_tokens": 1234, + "output_tokens": 56, + "cache_read_input_tokens": 78, + "cache_creation_input_tokens": 9, + "total_tokens": 1290, + "recorded_events": 3, + "has_recorded_usage": True, + }, + "last_usage": { + "input_tokens": 321, + "output_tokens": 6, + "cache_read_input_tokens": 7, + "cache_creation_input_tokens": 0, + "total_tokens": 327, + "recorded_events": 1, + "has_recorded_usage": True, + }, + "last_prompt_preview": ( + "User query:\ndeadline\n\nAvailable memory topic files:\n- filename: project-deadline.md" + ), + "last_response_preview": '{"files":["project-deadline.md"]}', + "last_prompt_chars": 93, + "last_response_chars": 32, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + assert "Memory Recall" in rendered + assert "3 total, 2 success, 1 failed, 1 cancelled" in rendered + assert "Last attempt" in rendered + assert "skipped in 412 ms, 0 files selected" in rendered + assert "Last side call" in rendered + assert "success in 411 ms, 2 files selected" in rendered + assert "project-deadline.md, feedback-testing.md" in rendered + assert "Side call usage" in rendered + assert "3 records, input 1,234, output 56, cache read 78, total 1,290" in rendered + assert "Last usage" in rendered + assert "input 321, output 6, cache read 7, total 327" in rendered + assert "recent input" not in rendered.lower() + assert "recent output" not in rendered.lower() + assert "Last input" not in rendered + assert "User query:" not in rendered + assert "Last output" not in rendered + assert '{"files":["project-deadline.md"]}' not in rendered + + @pytest.mark.asyncio async def test_status_uses_compiled_translations(monkeypatch) -> None: monkeypatch.setenv("LANGUAGE", "zh") diff --git a/tests/memory/test_memory_tools.py b/tests/memory/test_memory_tools.py index 0373e53..5edf180 100644 --- a/tests/memory/test_memory_tools.py +++ b/tests/memory/test_memory_tools.py @@ -112,6 +112,24 @@ async def test_write_memory_saves_and_returns_success(self): } ] + async def test_write_memory_returns_error_when_auto_memory_is_disabled(self): + manager = FakeMemoryManager() + tool = WriteMemoryTool(manager, is_enabled=lambda: False) + + result = await tool.execute( + tool_input={ + "name": "role", + "content": "Senior engineer", + "memory_type": "user", + "description": "Role", + }, + context=ToolContext(), + ) + + assert result.is_error is True + assert result.content == "Auto-memory is off." + assert manager.saved == [] + async def test_write_memory_returns_error_when_save_fails(self): class FailingManager(FakeMemoryManager): def save(self, *, name, content, memory_type, description): diff --git a/tests/memory/test_project_memory.py b/tests/memory/test_project_memory.py new file mode 100644 index 0000000..a955592 --- /dev/null +++ b/tests/memory/test_project_memory.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import importlib +import stat + + +def _module(): + return importlib.import_module("iac_code.memory.project_memory") + + +def test_project_memory_dir_uses_git_root_and_config_dir(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + repo = tmp_path / "repo" + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + git_dir = repo / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + memory_dir = mod.get_project_memory_dir(str(nested)) + + assert memory_dir == config_dir / "projects" / mod.project_key_for_cwd(str(repo)) / "memory" + + +def test_project_memory_runtime_exposes_instruction_files_and_auto_memory(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + runtime = mod.ProjectMemoryRuntime(str(project)) + + assert runtime.user_instruction_path == config_dir / "IAC-CODE.md" + assert runtime.project_instruction_path == project / "IAC-CODE.md" + assert runtime.auto_memory_dir == config_dir / "projects" / mod.project_key_for_cwd(str(project)) / "memory" + assert runtime.memory_manager._memory_dir == runtime.auto_memory_dir + + +def test_build_memory_context_reads_iac_code_files_and_memory_index_only(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + runtime = mod.ProjectMemoryRuntime(str(project)) + runtime.user_instruction_path.parent.mkdir(parents=True, exist_ok=True) + runtime.user_instruction_path.write_text("User instruction\n", encoding="utf-8") + runtime.project_instruction_path.write_text("Project instruction\n", encoding="utf-8") + runtime.memory_manager.save( + "topic-a", + content="Topic body should not be always injected", + memory_type="project", + description="Topic A", + ) + + context = runtime.build_memory_context() + + assert "User instruction" in context.instruction_memory_content + assert "Project instruction" in context.instruction_memory_content + assert "topic-a.md" in context.memory_index_content + assert "Topic body should not be always injected" not in context.memory_index_content + assert "read_memory" in context.memory_mechanics_content + assert "write_memory" in context.memory_mechanics_content + + +def test_ensure_user_instruction_file_returns_path_without_creating_empty_file(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + runtime = mod.ProjectMemoryRuntime(str(project)) + + created = runtime.ensure_instruction_file("user") + + assert created == config_dir / "IAC-CODE.md" + assert not created.exists() + + +def test_ensure_project_instruction_file_returns_path_without_creating_empty_file(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + project = tmp_path / "project" + project.mkdir() + project.chmod(0o755) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + runtime = mod.ProjectMemoryRuntime(str(project)) + + created = runtime.ensure_instruction_file("project") + + assert created == project / "IAC-CODE.md" + assert not created.exists() + assert stat.S_IMODE(project.stat().st_mode) == 0o755 + + +def test_auto_memory_enabled_defaults_to_true_and_persists(tmp_path, monkeypatch): + mod = _module() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + assert mod.is_auto_memory_enabled() is True + + mod.save_auto_memory_enabled(False) + + assert mod.is_auto_memory_enabled() is False diff --git a/tests/memory/test_recall.py b/tests/memory/test_recall.py new file mode 100644 index 0000000..33bb708 --- /dev/null +++ b/tests/memory/test_recall.py @@ -0,0 +1,550 @@ +from __future__ import annotations + +import asyncio +import json +import time +from types import SimpleNamespace + +import pytest + +from iac_code.memory.memory_manager import MemoryManager +from iac_code.types.stream_events import Usage + + +class FakeRecallProvider: + def __init__( + self, + text: str, + *, + delay: float = 0.0, + error: Exception | None = None, + usage: Usage | None = None, + ): + self.text = text + self.delay = delay + self.error = error + self.usage = usage + self.calls: list[dict[str, object]] = [] + + async def complete( + self, + messages, + system, + tools=None, + max_tokens=8192, + cache_policy="default", + ): + self.calls.append( + { + "messages": messages, + "system": system, + "tools": tools, + "max_tokens": max_tokens, + "cache_policy": cache_policy, + } + ) + if self.delay: + await asyncio.sleep(self.delay) + if self.error is not None: + raise self.error + return SimpleNamespace(text=self.text, usage=self.usage) + + +class FailingMemoryManager: + def list_memories(self): + raise OSError("memory dir unavailable") + + +@pytest.fixture +def memory_manager(tmp_path): + manager = MemoryManager(memory_dir=str(tmp_path)) + manager.save( + "project-deadline", + content="Freeze on 2026-06-15", + memory_type="project", + description="Project delivery schedule", + ) + manager.save( + "feedback-testing", + content="Prefer integration tests", + memory_type="feedback", + description="User testing preference", + ) + return manager + + +@pytest.mark.asyncio +async def test_recall_selects_valid_topic_files_and_reads_content(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md", "../escape.md", "missing.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + + result = await service.recall("what is the project deadline?") + + assert result.selected_files == ["project-deadline.md"] + assert "Freeze on 2026-06-15" in result.content + assert "Prefer integration tests" not in result.content + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 1 + assert stats["successful_side_queries"] == 1 + assert stats["failed_side_queries"] == 0 + assert stats["total_selected_files"] == 1 + assert stats["last_status"] == "success" + assert "User query:" in stats["last_prompt_preview"] + assert "project-deadline.md" in stats["last_prompt_preview"] + assert stats["last_response_preview"] == '{"files": ["project-deadline.md", "../escape.md", "missing.md"]}' + + +@pytest.mark.asyncio +async def test_recall_manifest_contains_frontmatter_not_topic_body(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": []})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + + await service.recall("deadline") + + call = provider.calls[0] + manifest_prompt = call["messages"][0].content + assert "Project delivery schedule" in manifest_prompt + assert "Freeze on 2026-06-15" not in manifest_prompt + + +@pytest.mark.asyncio +async def test_recall_manifest_uses_metadata_without_loading_topic_bodies(memory_manager, monkeypatch): + from iac_code.memory.recall import MemoryRecallService + + def fail_body_load(path): + raise AssertionError(f"body should not be loaded while building recall manifest: {path}") + + monkeypatch.setattr(memory_manager, "_load_memory_file", fail_body_load) + provider = FakeRecallProvider(json.dumps({"files": []})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + + result = await service.recall("deadline") + + assert result.status == "success" + assert provider.calls + + +@pytest.mark.asyncio +async def test_recall_handles_malformed_json_as_failed(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider("not json", usage=Usage(input_tokens=3, output_tokens=1)), + ) + + result = await service.recall("deadline") + + assert result.content == "" + assert result.selected_files == [] + assert result.usage == Usage(input_tokens=3, output_tokens=1) + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 1 + assert stats["failed_side_queries"] == 1 + assert stats["last_status"] == "failed" + + +@pytest.mark.asyncio +async def test_recall_stats_record_side_call_token_usage(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider( + json.dumps({"files": ["project-deadline.md"]}), + usage=Usage( + input_tokens=123, + output_tokens=12, + cache_read_input_tokens=4, + cache_creation_input_tokens=7, + ), + ), + ) + + await service.recall("deadline") + + stats = service.get_stats_snapshot() + assert stats["total_usage"] == { + "input_tokens": 123, + "output_tokens": 12, + "cache_read_input_tokens": 4, + "cache_creation_input_tokens": 7, + "total_tokens": 135, + "recorded_events": 1, + "has_recorded_usage": True, + } + assert stats["last_usage"] == { + "input_tokens": 123, + "output_tokens": 12, + "cache_read_input_tokens": 4, + "cache_creation_input_tokens": 7, + "total_tokens": 135, + "recorded_events": 1, + "has_recorded_usage": True, + } + + +@pytest.mark.asyncio +async def test_recall_side_call_disables_explicit_cache(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + + await service.recall("deadline") + + assert provider.calls[0]["cache_policy"] == "no_explicit_cache" + + +@pytest.mark.asyncio +async def test_recall_prompt_uses_configured_max_files(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": []})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider, max_files=2) + + await service.recall("deadline") + + assert "Select at most 2 files." in provider.calls[0]["system"] + + +@pytest.mark.asyncio +async def test_recall_handles_provider_error(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider("", error=RuntimeError("provider down")), + ) + + result = await service.recall("deadline") + + assert result.content == "" + assert service.get_stats_snapshot()["last_status"] == "failed" + + +@pytest.mark.asyncio +async def test_recall_handles_timeout(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=0.05), + timeout_seconds=0.001, + ) + + result = await service.recall("deadline") + + assert result.content == "" + stats = service.get_stats_snapshot() + assert stats["failed_side_queries"] == 1 + assert stats["last_status"] == "timeout" + + +@pytest.mark.asyncio +async def test_start_prefetch_returns_without_waiting_for_provider(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=0.05), + timeout_seconds=1.0, + ) + + started = time.monotonic() + prefetch = service.start_prefetch("deadline") + elapsed = time.monotonic() - started + + assert prefetch is not None + assert elapsed < 0.02 + assert prefetch.done() is False + + result = await prefetch.wait() + + assert result.selected_files == ["project-deadline.md"] + assert service.get_stats_snapshot()["last_status"] == "success" + + +@pytest.mark.asyncio +async def test_prefetch_uses_turn_lifetime_instead_of_sync_timeout(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=0.05), + timeout_seconds=0.001, + ) + + prefetch = service.start_prefetch("deadline") + assert prefetch is not None + + await asyncio.sleep(0.01) + + assert prefetch.done() is False + assert service.get_stats_snapshot()["last_status"] != "timeout" + + prefetch.cancel() + await asyncio.sleep(0) + + assert service.get_stats_snapshot()["last_status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_cancelled_prefetch_records_cancelled(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=1.0), + timeout_seconds=5.0, + ) + + prefetch = service.start_prefetch("deadline") + assert prefetch is not None + + prefetch.cancel() + await asyncio.sleep(0) + + stats = service.get_stats_snapshot() + assert stats["cancelled_side_queries"] == 1 + assert stats["total_side_queries"] >= stats["cancelled_side_queries"] + assert stats["last_status"] == "cancelled" + assert stats["last_selected_files"] == [] + assert stats["last_side_query_status"] == "cancelled" + assert stats["last_side_query_selected_files"] == [] + assert stats["last_side_query_duration_ms"] == 0 + + +@pytest.mark.asyncio +async def test_inflight_prefetch_filters_files_read_after_manifest_build(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=0.01) + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=provider, + ) + + prefetch = service.start_prefetch("deadline") + assert prefetch is not None + + for _ in range(20): + if provider.calls: + break + await asyncio.sleep(0) + assert provider.calls + + service.mark_files_read(["project-deadline.md"]) + + result = await prefetch.wait() + + assert result.content == "" + assert result.selected_files == [] + stats = service.get_stats_snapshot() + assert stats["last_status"] == "success" + assert stats["last_selected_files"] == [] + + +@pytest.mark.asyncio +async def test_skipped_recall_preserves_last_side_query_stats(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider( + json.dumps({"files": ["project-deadline.md"]}), + usage=Usage(input_tokens=10, output_tokens=2, cache_read_input_tokens=3), + ) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + + first = await service.recall("deadline") + assert first.selected_files == ["project-deadline.md"] + + service.mark_files_surfaced(first.selected_files) + service.mark_files_surfaced(["feedback-testing.md"]) + second = await service.recall("deadline") + + assert second.status == "skipped" + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 1 + assert stats["last_status"] == "skipped" + assert stats["last_selected_files"] == [] + assert stats["last_side_query_status"] == "success" + assert stats["last_side_query_selected_files"] == ["project-deadline.md"] + assert stats["last_usage"]["cache_read_input_tokens"] == 3 + + +@pytest.mark.asyncio +async def test_recall_result_does_not_suppress_until_marked_surfaced(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + first_provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=first_provider) + + first = await service.recall("deadline") + assert first.selected_files == ["project-deadline.md"] + + second_provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md", "feedback-testing.md"]})) + service._provider_manager = second_provider + + second = await service.recall("testing preference") + + assert second.selected_files == ["project-deadline.md", "feedback-testing.md"] + second_manifest = second_provider.calls[0]["messages"][0].content + assert "project-deadline.md" in second_manifest + assert "feedback-testing.md" in second_manifest + + +@pytest.mark.asyncio +async def test_recall_does_not_repeat_previously_surfaced_files(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + first_provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=first_provider) + + first = await service.recall("deadline") + assert first.selected_files == ["project-deadline.md"] + service.mark_files_surfaced(first.selected_files) + + second_provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md", "feedback-testing.md"]})) + service._provider_manager = second_provider + + second = await service.recall("testing preference") + + assert second.selected_files == ["feedback-testing.md"] + second_manifest = second_provider.calls[0]["messages"][0].content + assert "feedback-testing.md" in second_manifest + assert "project-deadline.md" not in second_manifest + + +@pytest.mark.asyncio +async def test_recall_excludes_memories_read_by_tool(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md", "feedback-testing.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider) + service.mark_files_read(["project-deadline.md"]) + + result = await service.recall("deadline and testing") + + assert result.selected_files == ["feedback-testing.md"] + manifest = provider.calls[0]["messages"][0].content + assert "feedback-testing.md" in manifest + assert "project-deadline.md" not in manifest + + +@pytest.mark.asyncio +async def test_recall_skips_when_no_topic_files(tmp_path): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["missing.md"]})) + service = MemoryRecallService(memory_manager=MemoryManager(memory_dir=str(tmp_path)), provider_manager=provider) + + result = await service.recall("anything") + + assert result.content == "" + assert provider.calls == [] + assert service.get_stats_snapshot()["last_status"] == "skipped" + + +@pytest.mark.asyncio +async def test_recall_skips_when_auto_memory_is_disabled(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})) + service = MemoryRecallService(memory_manager=memory_manager, provider_manager=provider, is_enabled=lambda: False) + + result = await service.recall("deadline") + + assert result.content == "" + assert result.status == "disabled" + assert provider.calls == [] + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 0 + assert stats["last_status"] == "disabled" + + +@pytest.mark.asyncio +async def test_recall_manifest_failure_degrades_to_failed_without_provider_call(): + from iac_code.memory.recall import MemoryRecallService + + provider = FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})) + service = MemoryRecallService(memory_manager=FailingMemoryManager(), provider_manager=provider) + + result = await service.recall("deadline") + + assert result.content == "" + assert result.selected_files == [] + assert provider.calls == [] + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 1 + assert stats["failed_side_queries"] == 1 + assert stats["last_status"] == "failed" + + +def test_recall_stats_can_be_reset(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]})), + ) + service._stats.total_side_queries = 1 + service._stats.successful_side_queries = 1 + service._stats.total_selected_files = 1 + service._stats.last_status = "success" + service._stats.last_selected_files = ["project-deadline.md"] + + service.reset_stats() + + assert service.get_stats_snapshot() == { + "total_side_queries": 0, + "successful_side_queries": 0, + "failed_side_queries": 0, + "cancelled_side_queries": 0, + "total_selected_files": 0, + "last_duration_ms": 0, + "last_status": "skipped", + "last_selected_files": [], + "last_side_query_duration_ms": 0, + "last_side_query_status": "skipped", + "last_side_query_selected_files": [], + "last_prompt_preview": "", + "last_response_preview": "", + "last_prompt_chars": 0, + "last_response_chars": 0, + "total_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + "last_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + } + + +def test_replace_surfaced_files_overwrites_process_local_state(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": []})), + ) + + service.mark_files_surfaced(["old.md"]) + service.replace_surfaced_files(["project-deadline.md"]) + + assert service.get_suppressed_files() == {"project-deadline.md"} diff --git a/tests/providers/test_anthropic_provider.py b/tests/providers/test_anthropic_provider.py index cb845d5..83f2e32 100644 --- a/tests/providers/test_anthropic_provider.py +++ b/tests/providers/test_anthropic_provider.py @@ -17,6 +17,44 @@ def test_convert_messages_user(self): assert api[0]["role"] == "user" assert api[0]["content"] == "Hello" + def test_convert_messages_merges_consecutive_user_messages(self): + from iac_code.agent.message import create_recalled_memory_message + + p = AnthropicProvider(model="claude-sonnet-4-6", api_key="test") + recalled_memory = create_recalled_memory_message("# Recalled Memory\nUse YAML", ["ros.md"]) + msgs = [ + Message.user("real user prompt"), + Message(role="user", content=recalled_memory.content), + ] + + api = p._convert_messages(msgs) + + assert len(api) == 1 + assert api[0]["role"] == "user" + assert api[0]["content"].startswith("real user prompt\n\n") + assert "Relevant persistent memories" in api[0]["content"] + + def test_convert_messages_merges_consecutive_mixed_content_messages(self): + p = AnthropicProvider(model="claude-sonnet-4-6", api_key="test") + msgs = [ + Message.user("tool result follows"), + Message.tool_result(tool_use_id="t1", content="done", is_error=False), + Message.assistant_text("first answer"), + Message.assistant_text("second answer"), + ] + + api = p._convert_messages(msgs) + + assert [message["role"] for message in api] == ["user", "assistant"] + assert api[0]["content"] == [ + {"type": "text", "text": "tool result follows"}, + {"type": "tool_result", "tool_use_id": "t1", "content": "done"}, + ] + assert api[1]["content"] == [ + {"type": "text", "text": "first answer"}, + {"type": "text", "text": "second answer"}, + ] + def test_convert_messages_tool_result(self): p = AnthropicProvider(model="claude-sonnet-4-6", api_key="test") msgs = [Message.tool_result(tool_use_id="t1", content="output", is_error=False)] diff --git a/tests/providers/test_dashscope_provider.py b/tests/providers/test_dashscope_provider.py index a1d13bc..604c7ef 100644 --- a/tests/providers/test_dashscope_provider.py +++ b/tests/providers/test_dashscope_provider.py @@ -148,6 +148,11 @@ def test_supported_model_prefixes(self, prefix): p = DashScopeProvider(model=prefix, api_key="k") assert p._supports_explicit_cache() + @pytest.mark.parametrize("model", ["qwen3.7-max", "qwen3.7-plus"]) + def test_qwen37_models_support_explicit_cache(self, model): + p = DashScopeProvider(model=model, api_key="k") + assert p._supports_explicit_cache() + def test_unsupported_model_returns_false(self): p = DashScopeProvider(model="kimi-k2.6", api_key="k") assert not p._supports_explicit_cache() @@ -197,6 +202,14 @@ def test_build_api_messages_empty_system(self): api = p._build_api_messages([Message.user("hi")], "") assert api[0]["role"] == "user" + def test_no_explicit_cache_policy_leaves_messages_plain(self): + p = DashScopeProvider(model="qwen3.5-plus", api_key="k") + system = f"STATIC\n\n{DYNAMIC_BOUNDARY}\n\nDYNAMIC" + api = p._build_api_messages([Message.user("hi")], system, cache_policy="no_explicit_cache") + + assert api[0] == {"role": "system", "content": system} + assert api[1] == {"role": "user", "content": "hi"} + def test_last_user_message_gets_cache_control(self): """Supported model: last user message is wrapped with cache_control.""" p = DashScopeProvider(model="qwen3.5-plus", api_key="k") @@ -228,6 +241,34 @@ def test_unsupported_model_no_user_cache_control(self): user_msg = api[-1] assert user_msg["content"] == "hello" + def test_recalled_memory_reminder_does_not_steal_user_cache_control(self): + """Provider-only recalled memory should not become the cache prefix marker.""" + p = DashScopeProvider(model="qwen3.5-plus", api_key="k") + msgs = [ + Message.user("actual user question"), + Message.user( + "\n" + "Relevant persistent memories recalled for this conversation:\n\n" + "# Recalled Memory\n" + "Prefer ROS YAML.\n" + "" + ), + ] + + api = p._build_api_messages(msgs, "sys") + + actual_user = api[1] + reminder = api[2] + assert actual_user["content"][0]["text"] == "actual user question" + assert actual_user["content"][0]["cache_control"] == {"type": "ephemeral"} + assert reminder["content"] == ( + "\n" + "Relevant persistent memories recalled for this conversation:\n\n" + "# Recalled Memory\n" + "Prefer ROS YAML.\n" + "" + ) + @pytest.mark.asyncio class TestDashScopeCacheMetrics: diff --git a/tests/services/test_agent_factory.py b/tests/services/test_agent_factory.py index 5604f28..2792124 100644 --- a/tests/services/test_agent_factory.py +++ b/tests/services/test_agent_factory.py @@ -3,6 +3,10 @@ from iac_code.services.agent_factory import AgentFactoryOptions, AgentRuntime, create_agent_runtime +def _current_time_line(prompt: str) -> str: + return next(line for line in prompt.splitlines() if line.startswith("- Current time: ")) + + def test_create_agent_runtime_uses_supplied_session_id(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) @@ -148,3 +152,76 @@ def fake_build_skill_listing(commands): skill_tool = runtime.tool_registry.get("skill") assert skill_tool is not None assert "disabled-skill" in skill_tool._disabled_skills + + +def test_create_agent_runtime_uses_project_memory_context(tmp_path, monkeypatch) -> None: + from iac_code.memory.memory_manager import MemoryManager + from iac_code.memory.project_memory import get_project_memory_dir + + project = tmp_path / "project" + project.mkdir() + config_dir = tmp_path / "config" + monkeypatch.chdir(project) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + (config_dir).mkdir() + (config_dir / "IAC-CODE.md").write_text("User memory instruction\n", encoding="utf-8") + (project / "IAC-CODE.md").write_text("Project memory instruction\n", encoding="utf-8") + topic_manager = MemoryManager(memory_dir=str(get_project_memory_dir(str(project)))) + topic_manager.save( + "topic-a", + "Topic body should not be always injected", + memory_type="project", + description="Topic A", + ) + + runtime = create_agent_runtime(AgentFactoryOptions(model="qwen3.6-plus", session_id="memory-runtime")) + + assert runtime.memory_manager._memory_dir == get_project_memory_dir(str(project)) + assert runtime.agent_loop._memory_recall_service is not None + assert "User memory instruction" in runtime.agent_loop.system_prompt + assert "Project memory instruction" in runtime.agent_loop.system_prompt + assert "topic-a.md" in runtime.agent_loop.system_prompt + assert "Topic body should not be always injected" not in runtime.agent_loop.system_prompt + + +def test_create_agent_runtime_exposes_legacy_memory_manager_for_hidden_command(tmp_path, monkeypatch) -> None: + from iac_code.config import get_config_dir + from iac_code.memory.project_memory import get_project_memory_dir + + project = tmp_path / "project" + project.mkdir() + config_dir = tmp_path / "config" + monkeypatch.chdir(project) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + runtime = create_agent_runtime(AgentFactoryOptions(model="qwen3.6-plus", session_id="memory-runtime")) + + assert runtime.memory_manager._memory_dir == get_project_memory_dir(str(project)) + assert runtime.legacy_memory_manager._memory_dir == get_config_dir() / "memory" + + +def test_system_prompt_refresher_reuses_runtime_current_time(tmp_path, monkeypatch) -> None: + from datetime import datetime as real_datetime + + from iac_code.agent import system_prompt + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + + runtime = create_agent_runtime( + AgentFactoryOptions(model="qwen3.7-max", session_id="time-stable", cwd=str(tmp_path)) + ) + + initial_line = _current_time_line(runtime.agent_loop.system_prompt) + refreshed_line = _current_time_line(runtime.agent_loop._system_prompt_refresher()) + + assert refreshed_line == initial_line diff --git a/tests/services/test_context_manager.py b/tests/services/test_context_manager.py index 6cb0852..83bf6ff 100644 --- a/tests/services/test_context_manager.py +++ b/tests/services/test_context_manager.py @@ -120,6 +120,19 @@ def test_build_compaction_prompt_excludes_recent(self): assert "User message 0" in prompt assert "User message 5" not in prompt + def test_build_compaction_prompt_excludes_recalled_memory_messages(self): + cm = ContextManager(system_prompt="sys", model="qwen") + cm.add_recalled_memory_message("# Recalled Memory\nhidden memory body", ["hidden-topic.md"]) + for i in range(6): + cm.add_user_message(f"User message {i}") + cm.add_assistant_message([TextBlock(text=f"Assistant response {i}")]) + + prompt = cm.build_compaction_prompt() + + assert "User message 0" in prompt + assert "hidden memory body" not in prompt + assert "hidden-topic.md" not in prompt + def test_apply_compaction_preserves_recent(self): cm = ContextManager(system_prompt="sys", model="qwen") for i in range(6): @@ -258,3 +271,30 @@ def count_tool_definitions(self, tools): cm.set_model("claude-opus-4-7") assert cm.get_usage()["tool_definition_tokens"] == 30 + + +def test_add_recalled_memory_message_tracks_surfaced_files(): + cm = ContextManager(system_prompt="sys", model="qwen") + + msg = cm.add_recalled_memory_message( + "# Recalled Memory\nUse YAML for ROS templates", + ["ros-yaml.md"], + ) + + assert msg.role == "user" + assert msg.metadata["type"] == "recalled_memory" + assert cm.get_surfaced_memory_files() == {"ros-yaml.md"} + assert "Use YAML for ROS templates" in cm.get_api_messages()[0]["content"] + + +def test_compaction_surfaced_files_come_from_retained_metadata_only(): + cm = ContextManager(system_prompt="sys", model="qwen") + cm.add_recalled_memory_message("# Recalled Memory\nOld memory", ["old.md"]) + for i in range(6): + cm.add_user_message(f"User message {i}") + cm.add_assistant_message(f"Assistant response {i}") + cm.add_recalled_memory_message("# Recalled Memory\nRecent memory", ["recent.md"]) + + cm.apply_compaction("Summary mentions old.md and recent.md") + + assert cm.get_surfaced_memory_files() == {"recent.md"} diff --git a/tests/services/test_session_index.py b/tests/services/test_session_index.py index 7a3ea40..503b104 100644 --- a/tests/services/test_session_index.py +++ b/tests/services/test_session_index.py @@ -7,7 +7,7 @@ import pytest -from iac_code.agent.message import Message, TextBlock, ToolResultBlock +from iac_code.agent.message import Message, TextBlock, ToolResultBlock, create_recalled_memory_message from iac_code.services.session_index import ( SessionIndex, extract_first_json_string_field, @@ -80,6 +80,28 @@ def test_last_prompt_meta_takes_precedence(self, storage): meta = read_lite_metadata(storage.session_path(cwd, "sy")) assert meta.last_prompt == "what user did last" + def test_recalled_memory_last_prompt_meta_is_ignored(self, storage): + cwd = "/proj/m" + storage.append(cwd, "sm", Message(role="user", content="real prompt"), git_branch=None) + storage.append_meta( + cwd, + "sm", + { + "type": "last-prompt", + "last_prompt": ( + "\n" + "Relevant persistent memories recalled for this conversation:\n\n" + "hidden\n" + "" + ), + }, + ) + + meta = read_lite_metadata(storage.session_path(cwd, "sm")) + + assert meta.last_prompt is None + assert meta.first_prompt == "real prompt" + def test_skips_tool_result_only_user_messages(self, storage): cwd = "/proj/z" # First user message is just a tool_result (e.g. session created via fork) — @@ -95,6 +117,20 @@ def test_skips_tool_result_only_user_messages(self, storage): meta = read_lite_metadata(storage.session_path(cwd, "sz")) assert meta.first_prompt == "real prompt" + def test_skips_recalled_memory_user_messages(self, storage): + cwd = "/proj/r" + storage.append( + cwd, + "sr", + create_recalled_memory_message("# Recalled Memory\nhidden prompt", ["topic.md"]), + git_branch=None, + ) + storage.append(cwd, "sr", Message(role="user", content="real prompt"), git_branch=None) + + meta = read_lite_metadata(storage.session_path(cwd, "sr")) + + assert meta.first_prompt == "real prompt" + # --------------------------------------------------------------------------- # SessionIndex diff --git a/tests/services/test_session_storage.py b/tests/services/test_session_storage.py index a698026..0048958 100644 --- a/tests/services/test_session_storage.py +++ b/tests/services/test_session_storage.py @@ -3,7 +3,14 @@ import pytest -from iac_code.agent.message import Message, TextBlock, ToolResultBlock, ToolUseBlock +from iac_code.agent.message import ( + Message, + TextBlock, + ToolResultBlock, + ToolUseBlock, + create_recalled_memory_message, + get_recalled_memory_files, +) from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME from iac_code.services.session_storage import SessionStorage from iac_code.services.session_usage import SessionUsageStore @@ -170,6 +177,18 @@ def test_new_session_uses_directory_format(storage): assert storage.load(CWD, "dir-session") == [Message(role="user", content="hi")] +def test_recalled_memory_metadata_round_trips(tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + msg = create_recalled_memory_message("# Recalled Memory\nUse YAML", ["ros-yaml.md"]) + + storage.append("/tmp/project", "session-1", msg) + loaded = storage.load("/tmp/project", "session-1") + + assert len(loaded) == 1 + assert get_recalled_memory_files(loaded[0]) == ["ros-yaml.md"] + assert "Use YAML" in loaded[0].get_text() + + def test_existing_legacy_session_stays_legacy_until_rename(storage): legacy_path = storage.legacy_session_path(CWD, "legacy") legacy_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/skills/test_command_registry.py b/tests/skills/test_command_registry.py index 3a67a79..2c7437c 100644 --- a/tests/skills/test_command_registry.py +++ b/tests/skills/test_command_registry.py @@ -67,6 +67,24 @@ def test_mixed_commands_in_get_all(self): names = {c.name for c in all_cmds} assert names == {"help", "simplify"} + def test_hidden_local_command_is_exact_only(self): + registry = CommandRegistry() + registry.register(LocalCommand(name="memory", description="Edit memory", handler=dummy_handler)) + registry.register( + LocalCommand( + name="memory-folder", + description="Legacy memory folder", + handler=dummy_handler, + hidden=True, + ) + ) + + assert registry.get("memory-folder") is not None + assert "memory-folder" not in {cmd.name for cmd in registry.get_all()} + assert "memory-folder" not in registry.get_completions("memory") + assert all(match.command.name != "memory-folder" for match in registry.fuzzy_search("memory-folder")) + assert registry.get_best_prefix_match("memory-f") is None + def test_skill_is_skill_property(self): cmd = _make_skill_command("test") assert cmd.is_skill is True diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 373d0d1..9845465 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -21,7 +21,7 @@ LOCALES_DIR = I18N_DIR / "locales" MEMORY_COMMAND_MSGIDS = { - "Usage: /memory [|search |delete |help]", + "Usage: /memory-folder [|search |delete |help]", "Saved memories:", "No memories saved yet.", "Matching memories:", diff --git a/tests/ui/dialogs/test_memory_dialog.py b/tests/ui/dialogs/test_memory_dialog.py new file mode 100644 index 0000000..c70bed0 --- /dev/null +++ b/tests/ui/dialogs/test_memory_dialog.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +from iac_code.ui.core.key_event import KeyEvent +from iac_code.ui.dialogs.memory import MemoryDialog + + +def test_memory_dialog_renders_auto_memory_with_blank_line_before_options(tmp_path): + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=True, + ) + + lines = dialog.render_lines() + + assert lines[0] == " Memory" + auto_index = lines.index(" ❯ Auto-memory: on") + project_index = lines.index(" 1. Project memory Saved in {}".format(tmp_path / "IAC-CODE.md")) + assert lines[auto_index + 1] == "" + assert project_index == auto_index + 2 + + +def test_memory_dialog_enter_toggles_auto_memory_when_focused(tmp_path): + toggled: list[bool] = [] + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=True, + on_toggle=toggled.append, + ) + + consumed = dialog.handle_key(KeyEvent("enter", "\n")) + + assert consumed is True + assert dialog.auto_memory_enabled is False + assert toggled == [False] + assert dialog.result is None + + +def test_memory_dialog_hides_auto_memory_folder_when_disabled(tmp_path): + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=False, + ) + + rendered = "\n".join(dialog.render_lines()) + + assert "Auto-memory: off" in rendered + assert "Open auto-memory folder" not in rendered + + +def test_memory_dialog_selects_project_after_moving_down_from_toggle(tmp_path): + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=True, + ) + + dialog.handle_key(KeyEvent("down", "")) + dialog.handle_key(KeyEvent("enter", "\n")) + + assert dialog.result == "project" + + +def test_memory_dialog_shows_folder_when_enabled_and_selects_it_as_third_option(tmp_path): + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=True, + ) + + rendered = "\n".join(dialog.render_lines()) + dialog.handle_key(KeyEvent("down", "")) + dialog.handle_key(KeyEvent("down", "")) + dialog.handle_key(KeyEvent("down", "")) + dialog.handle_key(KeyEvent("enter", "\n")) + + assert "3. Open auto-memory folder" in rendered + assert dialog.result == "folder" + + +def test_memory_dialog_can_initially_focus_folder_action(tmp_path): + dialog = MemoryDialog( + project_path=tmp_path / "IAC-CODE.md", + user_path=Path("~/.iac-code/IAC-CODE.md"), + auto_memory_dir=tmp_path / "memory", + auto_memory_enabled=True, + initial_focus_action="folder", + ) + + lines = dialog.render_lines() + + assert any(line.startswith(" ❯ 3. Open auto-memory folder") for line in lines) diff --git a/tests/ui/dialogs/test_memory_editor.py b/tests/ui/dialogs/test_memory_editor.py new file mode 100644 index 0000000..ca204ee --- /dev/null +++ b/tests/ui/dialogs/test_memory_editor.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from io import StringIO +from os import terminal_size + +from rich.cells import cell_len +from rich.console import Console + +from iac_code.ui.core.key_event import KeyEvent +from iac_code.ui.dialogs import memory_editor as memory_editor_module +from iac_code.ui.dialogs.memory_editor import FullscreenRenderer, VimMemoryEditor + + +def _keys(editor: VimMemoryEditor, keys: list[str]) -> None: + for key in keys: + char = key if len(key) == 1 else "" + editor.handle_key(KeyEvent(key=key, char=char)) + + +def test_vim_memory_editor_saves_only_when_content_changed(): + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + _keys(editor, ["i", "!", "escape", ":", "w", "q", "enter"]) + + assert editor.result is not None + assert editor.result.status == "saved" + assert editor.result.content == "!old" + + +def test_vim_memory_editor_reports_unchanged_on_wq_without_changes(): + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + _keys(editor, [":", "w", "q", "enter"]) + + assert editor.result is not None + assert editor.result.status == "unchanged" + assert editor.result.content == "old" + + +def test_vim_memory_editor_discards_changes_with_q_bang(): + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + _keys(editor, ["i", "!", "escape", ":", "q", "!", "enter"]) + + assert editor.result is not None + assert editor.result.status == "cancelled" + assert editor.result.content == "old" + + +def test_vim_memory_editor_supports_dd_delete_line(): + editor = VimMemoryEditor("one\ntwo", title="IAC-CODE.md") + + _keys(editor, ["d", "d", ":", "w", "q", "enter"]) + + assert editor.result is not None + assert editor.result.status == "saved" + assert editor.result.content == "two" + + +def test_vim_memory_editor_renders_focused_terminal_layout(): + editor = VimMemoryEditor("one\ntwo", title="Project memory", path="./IAC-CODE.md") + + lines = editor.render_lines() + + assert "Project memory" in lines[0] + assert "./IAC-CODE.md" in lines[0] + assert lines[1].startswith(" 1 │ one") + assert lines[2].startswith(" 2 │ two") + assert "NORMAL" in lines[-1] + assert ":wq save" in lines[-1] + + +def test_vim_memory_editor_leaves_last_terminal_column_empty(monkeypatch): + monkeypatch.setattr( + memory_editor_module.shutil, + "get_terminal_size", + lambda fallback: terminal_size((20, 6)), + ) + editor = VimMemoryEditor("one\ntwo", title="Project memory", path="./IAC-CODE.md") + + assert all(cell_len(line) <= 19 for line in editor.render_lines()) + + +def test_vim_memory_editor_exposes_cursor_position_inside_text_body(): + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + _keys(editor, ["i", "!", "escape"]) + + assert editor.cursor_position() == (1, 6) + + +def test_vim_memory_editor_cursor_position_uses_display_width_for_wide_chars(): + editor = VimMemoryEditor("测试", title="IAC-CODE.md") + + _keys(editor, ["a"]) + + assert editor.cursor_position() == (1, 7) + + +def test_vim_memory_editor_run_renders_with_cursor_position(): + class FakeRenderer: + def __init__(self): + self.cursor_positions: list[tuple[int, int] | None] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + def render(self, renderable, cursor_to=None): + self.cursor_positions.append(cursor_to) + + class FakeInput: + def __init__(self): + self.events = [ + KeyEvent("i", "i"), + KeyEvent("!", "!"), + KeyEvent("escape", ""), + KeyEvent(":", ":"), + KeyEvent("w", "w"), + KeyEvent("q", "q"), + KeyEvent("enter", "\n"), + ] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + def read_key(self, timeout=None): + return self.events.pop(0) if self.events else None + + renderer = FakeRenderer() + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + result = editor.run(renderer=renderer, input_capture=FakeInput()) + + assert result.status == "saved" + assert (1, 6) in renderer.cursor_positions + + +def test_vim_memory_editor_does_not_repaint_without_state_change(): + class FakeRenderer: + def __init__(self): + self.render_count = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + def render(self, renderable, cursor_to=None): + self.render_count += 1 + + class FakeInput: + def __init__(self): + self.events = [ + None, + KeyEvent("mouse", ""), + None, + KeyEvent(":", ":"), + KeyEvent("q", "q"), + KeyEvent("!", "!"), + KeyEvent("enter", "\n"), + ] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + def read_key(self, timeout=None): + return self.events.pop(0) if self.events else None + + renderer = FakeRenderer() + editor = VimMemoryEditor("old", title="IAC-CODE.md") + + result = editor.run(renderer=renderer, input_capture=FakeInput()) + + assert result.status == "cancelled" + assert renderer.render_count == 4 + + +def test_fullscreen_renderer_uses_alternate_screen_and_moves_cursor(): + stream = StringIO() + console = Console(file=stream, force_terminal=True, width=40, color_system=None) + + with FullscreenRenderer(console) as renderer: + renderer.render("hello", cursor_to=(2, 3)) + + output = stream.getvalue() + assert "\x1b[?1049h" in output + assert "\x1b[3;4H" in output + assert "\x1b[?1049l" in output + + +def test_fullscreen_renderer_does_not_scroll_before_moving_cursor(): + stream = StringIO() + console = Console(file=stream, force_terminal=True, width=20, color_system=None) + + with FullscreenRenderer(console) as renderer: + renderer.render("hello", cursor_to=(1, 5)) + + output = stream.getvalue() + assert "\r\n\x1b[2;6H" not in output diff --git a/tests/ui/dialogs/test_resume_picker.py b/tests/ui/dialogs/test_resume_picker.py index d55e314..301f6ea 100644 --- a/tests/ui/dialogs/test_resume_picker.py +++ b/tests/ui/dialogs/test_resume_picker.py @@ -9,7 +9,7 @@ import pytest from rich.console import Console as RichConsole -from iac_code.agent.message import Message +from iac_code.agent.message import Message, create_recalled_memory_message from iac_code.services.session_index import SessionEntry, SessionIndex from iac_code.services.session_storage import SessionStorage from iac_code.ui.core.key_event import KeyEvent @@ -107,6 +107,25 @@ def test_supplied_entries_are_used_without_index_reload(self): assert [entry.session_id for entry in p._all_entries] == ["candidate-a", "candidate-b"] + def test_fallback_preview_hides_recalled_memory_messages(self): + buffer = io.StringIO() + console = RichConsole(file=buffer, force_terminal=True, width=80, color_system=None) + + ResumePicker._fallback_render( + console, + [ + Message(role="user", content="visible question"), + create_recalled_memory_message("# Recalled Memory\nPrefer ROS YAML.", ["ros-yaml.md"]), + Message(role="assistant", content="visible answer"), + ], + ) + + output = buffer.getvalue() + assert "visible question" in output + assert "visible answer" in output + assert "Prefer ROS YAML" not in output + assert "Relevant persistent memories" not in output + def test_supplied_entries_are_not_reloaded_when_toggling_all_projects(self): index = MagicMock() index.list_for_cwd = MagicMock(side_effect=AssertionError("should not reload from index")) diff --git a/tests/ui/suggestions/test_aggregator.py b/tests/ui/suggestions/test_aggregator.py index d3c91bc..706591d 100644 --- a/tests/ui/suggestions/test_aggregator.py +++ b/tests/ui/suggestions/test_aggregator.py @@ -168,14 +168,14 @@ def test_accept_ghost_text_does_not_include_hint(self, aggregator): assert text == "/debug " def test_memory_argument_ghost_text(self, memory_aggregator): - """/memory d → ghost text completes the delete action.""" - memory_aggregator.update("/memory d", 9) + """/memory-folder d -> ghost text completes the delete action.""" + memory_aggregator.update("/memory-folder d", 16) assert memory_aggregator.ghost_text == "elete " def test_accept_memory_argument_suggestion_replaces_command_span(self, memory_aggregator): - """/memory delete suggestion replaces the full slash command token.""" - text = "/memory delete " + """/memory-folder delete suggestion replaces the full slash command token.""" + text = "/memory-folder delete " memory_aggregator.update(text, len(text)) result = memory_aggregator.accept_selected() - assert result == ("/memory delete user-role", 0, len(text)) + assert result == ("/memory-folder delete user-role", 0, len(text)) diff --git a/tests/ui/suggestions/test_command_provider.py b/tests/ui/suggestions/test_command_provider.py index a190b98..4df79d7 100644 --- a/tests/ui/suggestions/test_command_provider.py +++ b/tests/ui/suggestions/test_command_provider.py @@ -120,35 +120,40 @@ def test_arg_hint_absent_for_commands_without_one(self, provider): assert len(clear_items) == 1 assert clear_items[0].arg_hint is None - def test_memory_second_item_suggests_actions_and_memory_names(self, memory_provider): - """/memory → subcommands plus saved memory names.""" - token = make_token("/memory ") + def test_memory_folder_second_item_suggests_actions_and_memory_names(self, memory_provider): + """/memory-folder -> subcommands plus saved memory names.""" + token = make_token("/memory-folder ") items = memory_provider.provide(token) names = {item.display_text for item in items} assert {"search", "delete", "help", "user-role", "feedback-testing"}.issubset(names) - assert [item.completion for item in items if item.display_text == "search"] == ["/memory search "] - assert [item.completion for item in items if item.display_text == "user-role"] == ["/memory user-role"] + assert [item.completion for item in items if item.display_text == "search"] == ["/memory-folder search "] + assert [item.completion for item in items if item.display_text == "user-role"] == ["/memory-folder user-role"] - def test_memory_second_item_filters_action_prefix(self, memory_provider): - """/memory d → delete action suggestion.""" - token = make_token("/memory d") + def test_memory_folder_second_item_filters_action_prefix(self, memory_provider): + """/memory-folder d -> delete action suggestion.""" + token = make_token("/memory-folder d") items = memory_provider.provide(token) assert [item.display_text for item in items] == ["delete"] - assert items[0].completion == "/memory delete " + assert items[0].completion == "/memory-folder delete " - def test_memory_delete_suggests_memory_names(self, memory_provider): - """/memory delete → saved memory name suggestions.""" - token = make_token("/memory delete ") + def test_memory_folder_delete_suggests_memory_names(self, memory_provider): + """/memory-folder delete -> saved memory name suggestions.""" + token = make_token("/memory-folder delete ") items = memory_provider.provide(token) names = [item.display_text for item in items] assert names == ["feedback-testing", "user-role"] - assert items[0].completion == "/memory delete feedback-testing" + assert items[0].completion == "/memory-folder delete feedback-testing" assert all(item.id.startswith("cmd:memory:") for item in items) - def test_memory_search_query_has_no_argument_suggestions(self, memory_provider): - """/memory search leaves free-form search input alone.""" - token = make_token("/memory search ") + def test_memory_folder_search_query_has_no_argument_suggestions(self, memory_provider): + """/memory-folder search leaves free-form search input alone.""" + token = make_token("/memory-folder search ") + assert memory_provider.provide(token) == [] + + def test_visible_memory_command_has_no_legacy_argument_suggestions(self, memory_provider): + token = make_token("/memory ") + assert memory_provider.provide(token) == [] diff --git a/tests/ui/test_renderer_helpers.py b/tests/ui/test_renderer_helpers.py index 16863ff..f62d7af 100644 --- a/tests/ui/test_renderer_helpers.py +++ b/tests/ui/test_renderer_helpers.py @@ -6,6 +6,7 @@ import pytest from rich.console import Console +from iac_code.agent.message import Message, create_recalled_memory_message from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult from iac_code.tools.read_file import ReadFileTool from iac_code.types.stream_events import StackInstancesProgressEvent, StackProgressEvent @@ -163,6 +164,23 @@ def test_build_footer_uses_i18n_for_queued_message_section(self, monkeypatch): assert "下次工具调用后要提交的消息" in output assert "按 esc 中断并立即发送" in output + def test_replay_history_hides_recalled_memory_messages(self): + renderer = make_renderer() + + renderer.replay_history( + [ + Message(role="user", content="visible question"), + create_recalled_memory_message("# Recalled Memory\nPrefer ROS YAML.", ["ros-yaml.md"]), + Message(role="assistant", content="visible answer"), + ] + ) + + output = renderer.console.file.getvalue() + assert "visible question" in output + assert "visible answer" in output + assert "Prefer ROS YAML" not in output + assert "Relevant persistent memories" not in output + def test_any_segment_has_verbose_content(self): renderer = make_renderer() segments = [ diff --git a/tests/ui/test_repl_integration.py b/tests/ui/test_repl_integration.py index 8e7312b..52b499e 100644 --- a/tests/ui/test_repl_integration.py +++ b/tests/ui/test_repl_integration.py @@ -55,6 +55,10 @@ def make_session_entry(session_id: str, cwd: str, name: str | None = None): ) +def _current_time_line(prompt: str) -> str: + return next(line for line in prompt.splitlines() if line.startswith("- Current time: ")) + + class TestREPLProviderIntegration: @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @@ -117,6 +121,37 @@ def test_new_session_id_is_full_uuid(mock_mm, mock_ss, mock_pm): assert UUID4_RE.match(repl.session_id), f"expected UUID4, got {repl.session_id!r}" +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_repl_init_reuses_runtime_current_time_for_refresher(mock_mm, mock_ss, mock_pm, tmp_path, monkeypatch): + from datetime import datetime as real_datetime + + from iac_code.agent import system_prompt + from iac_code.ui import repl as repl_module + from iac_code.ui.repl import InlineREPL + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + monkeypatch.setattr(repl_module, "datetime", FakeDateTime, raising=False) + + repl = InlineREPL(model="qwen3.7-max") + + initial_line = _current_time_line(repl._agent_loop.system_prompt) + refreshed_line = _current_time_line(repl._build_current_system_prompt()) + + assert refreshed_line == initial_line + + def test_insert_text_delegates_to_prompt_input(): from iac_code.ui.repl import InlineREPL @@ -434,6 +469,53 @@ def test_swap_session_refreshes_session_trusted_read_directories(monkeypatch, tm assert custom_root in roots +def test_extract_last_user_text_skips_recalled_memory_message(): + from iac_code.agent.message import Message, create_recalled_memory_message + from iac_code.ui.repl import InlineREPL + + text = InlineREPL._extract_last_user_text( + [ + Message(role="user", content="real prompt"), + Message(role="assistant", content="answer"), + create_recalled_memory_message("# Recalled Memory\nhidden prompt", ["topic.md"]), + ] + ) + + assert text == "real prompt" + + +def test_history_search_messages_skips_recalled_memory_messages_and_leaked_entries(): + from iac_code.agent.message import RECALLED_MEMORY_MARKER, Message, create_recalled_memory_message + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._history = SimpleNamespace( + entries=Mock( + return_value=[ + "normal history", + f"\n{RECALLED_MEMORY_MARKER}:\n\nhidden\n", + ] + ) + ) + repl._agent_loop = SimpleNamespace( + context_manager=SimpleNamespace( + get_messages=Mock( + return_value=[ + create_recalled_memory_message("# Recalled Memory\nhidden context", ["topic.md"]), + Message(role="user", content="context prompt"), + ] + ) + ) + ) + + messages = repl._history_search_messages() + + assert messages == [ + {"role": "user", "content": "normal history"}, + {"role": "user", "content": "context prompt"}, + ] + + def test_print_exit_text_uses_session_name_and_prints_session_id(): from rich.text import Text diff --git a/tests/ui/test_repl_status.py b/tests/ui/test_repl_status.py index 5b3db90..4aad6e1 100644 --- a/tests/ui/test_repl_status.py +++ b/tests/ui/test_repl_status.py @@ -1,11 +1,15 @@ from types import SimpleNamespace from unittest.mock import MagicMock -from iac_code.agent.message import Message, ToolResultBlock +from iac_code.agent.message import Message, ToolResultBlock, create_recalled_memory_message from iac_code.state.app_state import AppState, AppStateStore from iac_code.ui.repl import InlineREPL +def _current_time_line(prompt: str) -> str: + return next(line for line in prompt.splitlines() if line.startswith("- Current time: ")) + + def test_count_user_turns_ignores_tool_result_messages() -> None: messages = [ Message(role="user", content="first"), @@ -17,6 +21,17 @@ def test_count_user_turns_ignores_tool_result_messages() -> None: assert InlineREPL._count_user_turns(messages) == 2 +def test_count_user_turns_ignores_recalled_memory_messages() -> None: + messages = [ + Message(role="user", content="first"), + create_recalled_memory_message("# Recalled Memory\nPrefer ROS YAML.", ["ros-yaml.md"]), + Message(role="assistant", content="answer"), + Message(role="user", content="second"), + ] + + assert InlineREPL._count_user_turns(messages) == 2 + + def test_status_snapshot_uses_agent_loop_and_original_cwd(monkeypatch) -> None: repl = object.__new__(InlineREPL) repl._session_id = "abc123" @@ -42,6 +57,15 @@ def test_status_snapshot_uses_agent_loop_and_original_cwd(monkeypatch) -> None: "context_window": 128000, "usage_percent": 45.3125, } + repl._agent_loop.get_memory_recall_stats.return_value = { + "total_side_queries": 1, + "successful_side_queries": 1, + "failed_side_queries": 0, + "total_selected_files": 2, + "last_duration_ms": 50, + "last_status": "success", + "last_selected_files": ["topic.md"], + } repl._agent_loop.context_manager.get_messages.return_value = [ Message(role="user", content="first"), Message(role="assistant", content="answer"), @@ -65,6 +89,7 @@ def test_status_snapshot_uses_agent_loop_and_original_cwd(monkeypatch) -> None: assert snapshot["max_turns"] == 100 assert snapshot["api_usage"].total_tokens == 18 assert snapshot["context_usage"]["usage_percent"] == 45.3125 + assert snapshot["memory_recall"]["last_selected_files"] == ["topic.md"] def test_status_snapshot_uses_runtime_provider_manager(monkeypatch) -> None: @@ -84,6 +109,7 @@ def test_status_snapshot_uses_runtime_provider_manager(monkeypatch) -> None: has_recorded_usage=False, ) repl._agent_loop.get_context_usage.return_value = {} + repl._agent_loop.get_memory_recall_stats.return_value = {"last_status": "skipped"} repl._agent_loop.context_manager.get_messages.return_value = [] monkeypatch.setattr("iac_code.ui.repl.get_active_provider_key", lambda: "openai") @@ -96,3 +122,30 @@ def test_status_snapshot_uses_runtime_provider_manager(monkeypatch) -> None: assert snapshot["provider"] == "Runtime Provider" assert snapshot["model"] == "runtime-model" + + +def test_current_system_prompt_uses_repl_runtime_current_time(tmp_path, monkeypatch) -> None: + from datetime import datetime as real_datetime + + from iac_code.agent import system_prompt + + class FakeDateTime: + calls = 0 + + @classmethod + def now(cls): + cls.calls += 1 + return real_datetime(2026, 6, 5, 10, cls.calls, 0) + + repl = object.__new__(InlineREPL) + repl._memory_runtime = SimpleNamespace(build_memory_context=lambda: None) + repl._skill_listing = "" + repl._runtime_current_time = "2026-06-05 10:00:00" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(system_prompt, "datetime", FakeDateTime) + + first = repl._build_current_system_prompt() + second = repl._build_current_system_prompt() + + assert first == second + assert _current_time_line(first) == "- Current time: 2026-06-05 10:00:00" diff --git a/website/docs/cli/commands.md b/website/docs/cli/commands.md index 8257c29..6121d5f 100644 --- a/website/docs/cli/commands.md +++ b/website/docs/cli/commands.md @@ -20,11 +20,21 @@ Text after the command name is passed as arguments. In the table below, `` | `/effort [level]` | Show or change thinking effort for the active model when the selected model supports effort control. With a level, it applies the requested value if valid for the model. Without a level, it opens an interactive picker in the REPL, or prints the current effort in non-interactive contexts. | | `/exit` | Exit the interactive REPL. Aliases: `/quit`, `/q`. | | `/help` | Show available commands and common keyboard shortcuts inside the REPL. Alias: `/?`. | -| `/memory [\|search \|delete \|help]` | List, view, search, or delete saved memories. Natural-language memory creation is still handled by the assistant through the memory tool when you ask it to remember something. | +| `/memory` | Open the memory selector. Edit project or user `IAC-CODE.md` files, toggle auto-memory, and open the project auto-memory folder when auto-memory is on. | | `/model [model_name]` | Show or switch the active model. With `model_name`, it switches directly to that model for the active provider. Without an argument, it opens an interactive model picker when a provider is configured, or prints the current model when no console UI is available. | | `/rename ` | Name the current session. Names appear in the welcome banner, exit hint, and `/resume` picker, and can be used with `/resume` or `--resume` when they uniquely identify a session. | | `/resume [session id\|unique id prefix\|unique session name]` | Resume a previous session. With an argument, IaC Code resolves it as an exact session ID, unique ID prefix, or unique session name. Without an argument, it opens the interactive session picker. Cross-project sessions print a `cd ... && iac-code --resume ` command instead of hot-swapping the current project. | | `/skills` | Open the skill management picker. Search skills, sort by name/source/size, and enable or disable user and project skills. Bundled skills remain locked on. | -| `/status` | Show current session ID, provider, model, Alibaba Cloud region, working directory, recorded API token usage, turn count, and context utilization. | +| `/status` | Show current session ID, provider, model, Alibaba Cloud region, working directory, recorded API token usage, turn count, and context utilization. In debug mode, it also shows memory recall side-call counts and token usage. | The exact command list can change between releases. Use `/help` or type `/` in the REPL to inspect the commands available in your installed version. + +## Memory + +Use `/memory` to edit the memory files IaC Code loads into the conversation: + +- Project memory is saved in `IAC-CODE.md` at the project root. +- User memory is saved in `IAC-CODE.md` in the runtime configuration directory, `~/.iac-code/` by default. +- The editor is a compact Vim-like full-screen editor. Use `i`, `a`, or `o` to enter insert mode, `Esc` to return to normal mode, `:wq` to save, and `:q!` to discard. +- The `Auto-memory` row can be toggled with `Enter`. When auto-memory is on, IaC Code can recall relevant project topic memories as hidden conversation context. +- The auto-memory folder option appears only when auto-memory is on. diff --git a/website/docs/cli/interactive-mode.md b/website/docs/cli/interactive-mode.md index 2e33bd3..726c999 100644 --- a/website/docs/cli/interactive-mode.md +++ b/website/docs/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## Commands -Type `/` to discover available slash commands. Common operational commands include `/status` for the current session state, `/skills` for skill management, `/memory` for saved memories, `/rename` for naming the active session, and `/resume` for switching sessions. +Type `/` to discover available slash commands. Common operational commands include `/status` for the current session state, `/skills` for skill management, `/memory` for project and user memory files, `/rename` for naming the active session, and `/resume` for switching sessions. Type `$` to discover and invoke skills only. diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index 5263ba3..bbe1081 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ Common files: | `.credentials.yml` | LLM credentials | | `.cloud-credentials.yml` | Cloud provider credentials | | `settings.yml` | Selected provider, model, and related settings | +| `IAC-CODE.md` | User memory loaded as persistent instructions | | history files | Input history for interactive workflows | Avoid committing or sharing files from this directory because they can contain secrets or local preferences. +## Memory Files + +IaC Code has two public memory locations: + +| Location | Purpose | +|---|---| +| `/IAC-CODE.md` | Project memory. This can be committed when the instructions are useful for everyone working in the project. | +| `/IAC-CODE.md` | User memory. This follows `IAC_CODE_CONFIG_DIR` and is private to the local user. | + +Project auto-memory topic files are stored under: + +```text +/projects//memory/ +``` + +`MEMORY.md` in that folder is the topic index. When auto-memory is on, IaC Code may use a side call to select relevant topic files and add them as hidden conversation context. + ## Project Settings In addition to the user-level `~/.iac-code/settings.yml`, IaC Code loads project-level settings from the current working directory: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md index 814a395..4c7e5da 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ Text nach dem Befehlsnamen wird als Argumente uebergeben. In der folgenden Tabel | `/effort [level]` | Zeigen oder aendern Sie den Denkaufwand fuer das aktive Modell, wenn das ausgewaehlte Modell Aufwandsteuerung unterstuetzt. Mit einem Level wird der angeforderte Wert angewendet, wenn er fuer das Modell gueltig ist. Ohne Level wird im REPL eine interaktive Auswahl geoeffnet, oder der aktuelle Aufwand wird in nicht-interaktiven Kontexten ausgegeben. | | `/exit` | Beenden Sie das interaktive REPL. Aliase: `/quit`, `/q`. | | `/help` | Zeigen Sie verfuegbare Befehle und gaengige Tastenkuerzel im REPL an. Alias: `/?`. | -| `/memory [\|search \|delete \|help]` | Gespeicherte Erinnerungen auflisten, anzeigen, durchsuchen oder löschen. Das Erstellen von Erinnerungen in natürlicher Sprache erledigt weiterhin der Assistent über das Memory-Werkzeug, wenn Sie ihn bitten, sich etwas zu merken. | +| `/memory` | Öffnet die Speicherauswahl. Bearbeiten Sie Projekt- oder Benutzerdateien `IAC-CODE.md`, schalten Sie auto-memory ein oder aus und öffnen Sie den auto-memory-Ordner des Projekts, wenn auto-memory aktiviert ist. | | `/model [model_name]` | Zeigen oder wechseln Sie das aktive Modell. Mit `model_name` wird direkt zu diesem Modell fuer den aktiven Anbieter gewechselt. Ohne Argument wird eine interaktive Modellauswahl geoeffnet, wenn ein Anbieter konfiguriert ist, oder das aktuelle Modell wird ausgegeben, wenn keine Konsolen-UI verfuegbar ist. | | `/rename ` | Die aktuelle Sitzung benennen. Namen erscheinen im Willkommensbanner, im Exit-Hinweis und in der `/resume`-Auswahl und können mit `/resume` oder `--resume` verwendet werden, wenn sie eine Sitzung eindeutig identifizieren. | | `/resume [sitzungs-id\|eindeutiges-id-präfix\|eindeutiger-sitzungsname]` | Eine frühere Sitzung fortsetzen. Mit einem Argument löst IaC Code es als exakte Sitzungs-ID, eindeutiges ID-Präfix oder eindeutigen Sitzungsnamen auf. Ohne Argument wird die interaktive Sitzungsauswahl geöffnet. Projektübergreifende Sitzungen geben einen `cd ... && iac-code --resume `-Befehl aus, anstatt das aktuelle Projekt direkt zu wechseln. | | `/skills` | Die Skill-Verwaltungsauswahl öffnen. Skills nach Name oder Beschreibung suchen, nach Name/Quelle/Größe sortieren und Benutzer- oder Projekt-Skills aktivieren oder deaktivieren. Gebündelte Skills bleiben gesperrt aktiviert. | -| `/status` | Aktuelle Sitzungs-ID, Anbieter, Modell, Alibaba Cloud-Region, Arbeitsverzeichnis, aufgezeichnete API-Token-Nutzung, Rundenzahl und Kontextauslastung anzeigen. | +| `/status` | Aktuelle Sitzungs-ID, Anbieter, Modell, Alibaba Cloud-Region, Arbeitsverzeichnis, aufgezeichnete API-Token-Nutzung, Rundenzahl und Kontextauslastung anzeigen. Im Debug-Modus werden außerdem Speicherabruf-Side-Call-Zähler und deren Token-Nutzung angezeigt. | Die genaue Befehlsliste kann sich zwischen Versionen aendern. Verwenden Sie `/help` oder tippen Sie `/` im REPL, um die in Ihrer installierten Version verfuegbaren Befehle anzuzeigen. + +## Speicher + +Verwenden Sie `/memory`, um die Speicherdateien zu bearbeiten, die IaC Code in die Unterhaltung lädt: + +- Projektspeicher wird in `IAC-CODE.md` im Projektstamm gespeichert. +- Benutzerspeicher wird in `IAC-CODE.md` im Laufzeit-Konfigurationsverzeichnis gespeichert, standardmäßig `~/.iac-code/`. +- Der Editor ist ein kompakter Vollbild-Editor im Vim-Stil. Verwenden Sie `i`, `a` oder `o`, um in den Einfügemodus zu wechseln, `Esc`, um zum Normalmodus zurückzukehren, `:wq` zum Speichern und `:q!` zum Verwerfen. +- Die Zeile `Auto-memory` kann mit `Enter` umgeschaltet werden. Wenn auto-memory aktiviert ist, kann IaC Code relevante Projektthemen-Speicher als versteckten Unterhaltungskontext abrufen. +- Die Option für den auto-memory-Ordner erscheint nur, wenn auto-memory aktiviert ist. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index aaabb00..fd5997b 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## Befehle -Tippen Sie `/`, um verfügbare Slash-Befehle zu entdecken. Zu den häufigen Betriebsbefehlen gehören `/status` für den aktuellen Sitzungszustand, `/skills` für die Skill-Verwaltung, `/memory` für gespeicherte Erinnerungen, `/rename` zum Benennen der aktiven Sitzung und `/resume` zum Wechseln zwischen Sitzungen. +Tippen Sie `/`, um verfügbare Slash-Befehle zu entdecken. Zu den häufigen Betriebsbefehlen gehören `/status` für den aktuellen Sitzungszustand, `/skills` für die Skill-Verwaltung, `/memory` für Projekt- und Benutzerspeicherdateien, `/rename` zum Benennen der aktiven Sitzung und `/resume` zum Wechseln zwischen Sitzungen. Tippen Sie `$`, um ausschließlich Skills zu entdecken und aufzurufen. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 81000cd..06ea878 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ Häufige Dateien: | `.credentials.yml` | LLM-Anmeldedaten | | `.cloud-credentials.yml` | Cloud-Anbieter-Anmeldedaten | | `settings.yml` | Ausgewählter Anbieter, Modell und zugehörige Einstellungen | +| `IAC-CODE.md` | Benutzerspeicher, der als dauerhafte Anweisungen geladen wird | | Verlaufsdateien | Eingabeverlauf für interaktive Workflows | Vermeiden Sie es, Dateien aus diesem Verzeichnis zu committen oder zu teilen, da sie Geheimnisse oder lokale Einstellungen enthalten können. +## Speicherdateien + +IaC Code hat zwei öffentliche Speicherorte: + +| Speicherort | Zweck | +|---|---| +| `/IAC-CODE.md` | Projektspeicher. Er kann committet werden, wenn die Anweisungen für alle Personen nützlich sind, die am Projekt arbeiten. | +| `/IAC-CODE.md` | Benutzerspeicher. Er folgt `IAC_CODE_CONFIG_DIR` und ist privat für den lokalen Benutzer. | + +Projektbezogene auto-memory-Themendateien werden hier gespeichert: + +```text +/projects//memory/ +``` + +`MEMORY.md` in diesem Ordner ist der Themenindex. Wenn auto-memory aktiviert ist, kann IaC Code mit einem Side Call relevante Themendateien auswählen und sie als versteckten Unterhaltungskontext hinzufügen. + ## Projekteinstellungen Zusätzlich zur benutzerbezogenen `~/.iac-code/settings.yml` lädt IaC Code projektbezogene Einstellungen aus dem aktuellen Arbeitsverzeichnis: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md index 964e210..fd0b80d 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ El texto despues del nombre del comando se pasa como argumentos. En la tabla sig | `/effort [level]` | Muestra o cambia el esfuerzo de pensamiento para el modelo activo cuando el modelo seleccionado admite control de esfuerzo. Con un nivel, aplica el valor solicitado si es valido para el modelo. Sin un nivel, abre un selector interactivo en el REPL, o imprime el esfuerzo actual en contextos no interactivos. | | `/exit` | Sale del REPL interactivo. Alias: `/quit`, `/q`. | | `/help` | Muestra los comandos disponibles y los atajos de teclado comunes dentro del REPL. Alias: `/?`. | -| `/memory [\|search \|delete \|help]` | Listar, ver, buscar o eliminar memorias guardadas. La creación de memorias en lenguaje natural sigue a cargo del asistente mediante la herramienta de memoria cuando le pides que recuerde algo. | +| `/memory` | Abre el selector de memoria. Edita los archivos `IAC-CODE.md` de proyecto o usuario, activa o desactiva auto-memory y abre la carpeta de auto-memory del proyecto cuando auto-memory está activada. | | `/model [model_name]` | Muestra o cambia el modelo activo. Con `model_name`, cambia directamente a ese modelo para el proveedor activo. Sin argumento, abre un selector interactivo de modelos cuando hay un proveedor configurado, o imprime el modelo actual cuando no hay interfaz de consola disponible. | | `/rename ` | Nombrar la sesión actual. Los nombres aparecen en el banner de bienvenida, en la sugerencia de salida y en el selector de `/resume`, y pueden usarse con `/resume` o `--resume` cuando identifican una sesión de forma única. | | `/resume [id-de-sesion\|prefijo-unico-de-id\|nombre-unico-de-sesion]` | Reanudar una sesión anterior. Con un argumento, IaC Code lo resuelve como ID exacto, prefijo único de ID o nombre único de sesión. Sin argumento, abre el selector interactivo de sesiones. Las sesiones de otros proyectos imprimen un comando `cd ... && iac-code --resume ` en lugar de cambiar en caliente el proyecto actual. | | `/skills` | Abrir el selector de gestión de habilidades. Busca habilidades por nombre o descripción, ordena por nombre/origen/tamaño y activa o desactiva habilidades de usuario o de proyecto. Las habilidades incluidas permanecen bloqueadas y activadas. | -| `/status` | Mostrar el ID de sesión actual, proveedor, modelo, región de Alibaba Cloud, directorio de trabajo, uso registrado de tokens de API, número de turnos y utilización del contexto. | +| `/status` | Mostrar el ID de sesión actual, proveedor, modelo, región de Alibaba Cloud, directorio de trabajo, uso registrado de tokens de API, número de turnos y utilización del contexto. En modo de depuración también muestra los recuentos de side calls de memoria y su uso de tokens. | La lista exacta de comandos puede cambiar entre versiones. Usa `/help` o escribe `/` en el REPL para inspeccionar los comandos disponibles en tu version instalada. + +## Memoria + +Usa `/memory` para editar los archivos de memoria que IaC Code carga en la conversación: + +- La memoria del proyecto se guarda en `IAC-CODE.md` en la raíz del proyecto. +- La memoria de usuario se guarda en `IAC-CODE.md` dentro del directorio de configuración en tiempo de ejecución, `~/.iac-code/` de forma predeterminada. +- El editor es un editor compacto de pantalla completa similar a Vim. Usa `i`, `a` u `o` para entrar en modo de inserción, `Esc` para volver al modo normal, `:wq` para guardar y `:q!` para descartar. +- La fila `Auto-memory` se puede alternar con `Enter`. Cuando auto-memory está activada, IaC Code puede recuperar memorias de temas del proyecto como contexto de conversación oculto. +- La opción de carpeta de auto-memory solo aparece cuando auto-memory está activada. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 80811f1..bb56b8a 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## Comandos -Escribe `/` para descubrir los comandos slash disponibles. Entre los comandos operativos habituales están `/status` para el estado de la sesión actual, `/skills` para gestionar habilidades, `/memory` para las memorias guardadas, `/rename` para nombrar la sesión activa y `/resume` para cambiar de sesión. +Escribe `/` para descubrir los comandos slash disponibles. Entre los comandos operativos habituales están `/status` para el estado de la sesión actual, `/skills` para gestionar habilidades, `/memory` para los archivos de memoria de proyecto y usuario, `/rename` para nombrar la sesión activa y `/resume` para cambiar de sesión. Escribe `$` para descubrir e invocar solo habilidades. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 0a3525c..9880d8d 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ Archivos comunes: | `.credentials.yml` | Credenciales de LLM | | `.cloud-credentials.yml` | Credenciales del proveedor de nube | | `settings.yml` | Proveedor seleccionado, modelo y configuraciones relacionadas | +| `IAC-CODE.md` | Memoria de usuario cargada como instrucciones persistentes | | history files | Historial de entrada para flujos de trabajo interactivos | Evite hacer commit o compartir archivos de este directorio porque pueden contener secretos o preferencias locales. +## Archivos de memoria + +IaC Code tiene dos ubicaciones públicas de memoria: + +| Ubicación | Propósito | +|---|---| +| `/IAC-CODE.md` | Memoria del proyecto. Puede hacerse commit cuando las instrucciones son útiles para todas las personas que trabajan en el proyecto. | +| `/IAC-CODE.md` | Memoria de usuario. Sigue `IAC_CODE_CONFIG_DIR` y es privada para el usuario local. | + +Los archivos de temas de auto-memory del proyecto se almacenan en: + +```text +/projects//memory/ +``` + +`MEMORY.md` en esa carpeta es el índice de temas. Cuando auto-memory está activada, IaC Code puede usar una side call para seleccionar archivos de temas relevantes y añadirlos como contexto oculto de la conversación. + ## Configuración del proyecto Además del archivo `~/.iac-code/settings.yml` a nivel de usuario, IaC Code carga configuraciones a nivel de proyecto desde el directorio de trabajo actual: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md index 0a26d40..fb4b185 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ Le texte après le nom de la commande est transmis comme arguments. Dans le tabl | `/effort [level]` | Afficher ou modifier l'effort de réflexion pour le modèle actif lorsque le modèle sélectionné prend en charge le contrôle d'effort. Avec un niveau, il applique la valeur demandée si elle est valide pour le modèle. Sans niveau, il ouvre un sélecteur interactif dans le REPL, ou affiche l'effort actuel dans les contextes non interactifs. | | `/exit` | Quitter le REPL interactif. Alias : `/quit`, `/q`. | | `/help` | Afficher les commandes disponibles et les raccourcis clavier courants dans le REPL. Alias : `/?`. | -| `/memory [\|search \|delete \|help]` | Lister, afficher, rechercher ou supprimer les mémoires enregistrées. La création de mémoires en langage naturel reste gérée par l'assistant via l'outil de mémoire lorsque vous lui demandez de se souvenir de quelque chose. | +| `/memory` | Ouvrir le sélecteur de mémoire. Modifiez les fichiers `IAC-CODE.md` de projet ou d'utilisateur, activez ou désactivez auto-memory, et ouvrez le dossier auto-memory du projet lorsque auto-memory est activé. | | `/model [model_name]` | Afficher ou changer le modèle actif. Avec `model_name`, il bascule directement vers ce modèle pour le fournisseur actif. Sans argument, il ouvre un sélecteur de modèle interactif lorsqu'un fournisseur est configuré, ou affiche le modèle actuel lorsqu'aucune interface console n'est disponible. | | `/rename ` | Nommer la session actuelle. Les noms apparaissent dans la bannière d'accueil, l'indication de sortie et le sélecteur `/resume`, et peuvent être utilisés avec `/resume` ou `--resume` lorsqu'ils identifient une session de façon unique. | | `/resume [id-de-session\|préfixe-id-unique\|nom-de-session-unique]` | Reprendre une session précédente. Avec un argument, IaC Code le résout comme identifiant exact, préfixe d'identifiant unique ou nom de session unique. Sans argument, il ouvre le sélecteur de session interactif. Les sessions inter-projets affichent une commande `cd ... && iac-code --resume ` au lieu de basculer le projet courant à chaud. | | `/skills` | Ouvrir le sélecteur de gestion des compétences. Recherchez par nom ou description, triez par nom/source/taille et activez ou désactivez les compétences utilisateur ou projet. Les compétences intégrées restent verrouillées et activées. | -| `/status` | Afficher l'ID de session actuel, le fournisseur, le modèle, la région Alibaba Cloud, le répertoire de travail, l'utilisation enregistrée des tokens d'API, le nombre de tours et l'utilisation du contexte. | +| `/status` | Afficher l'ID de session actuel, le fournisseur, le modèle, la région Alibaba Cloud, le répertoire de travail, l'utilisation enregistrée des tokens d'API, le nombre de tours et l'utilisation du contexte. En mode debug, affiche également les compteurs de side calls de mémoire et leur consommation de tokens. | La liste exacte des commandes peut varier entre les versions. Utilisez `/help` ou tapez `/` dans le REPL pour inspecter les commandes disponibles dans votre version installée. + +## Mémoire + +Utilisez `/memory` pour modifier les fichiers mémoire que IaC Code charge dans la conversation : + +- La mémoire de projet est enregistrée dans `IAC-CODE.md` à la racine du projet. +- La mémoire utilisateur est enregistrée dans `IAC-CODE.md` dans le répertoire de configuration d'exécution, `~/.iac-code/` par défaut. +- L'éditeur est un éditeur plein écran compact de style Vim. Utilisez `i`, `a` ou `o` pour entrer en mode insertion, `Esc` pour revenir au mode normal, `:wq` pour enregistrer et `:q!` pour abandonner. +- La ligne `Auto-memory` se bascule avec `Enter`. Lorsque auto-memory est activé, IaC Code peut rappeler des mémoires de sujet du projet comme contexte de conversation masqué. +- L'option de dossier auto-memory n'apparaît que lorsque auto-memory est activé. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 139a0c7..b04917f 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## Commandes -Tapez `/` pour découvrir les commandes slash disponibles. Les commandes opérationnelles courantes incluent `/status` pour l'état de la session actuelle, `/skills` pour la gestion des compétences, `/memory` pour les mémoires enregistrées, `/rename` pour nommer la session active et `/resume` pour changer de session. +Tapez `/` pour découvrir les commandes slash disponibles. Les commandes opérationnelles courantes incluent `/status` pour l'état de la session actuelle, `/skills` pour la gestion des compétences, `/memory` pour les fichiers mémoire de projet et d'utilisateur, `/rename` pour nommer la session active et `/resume` pour changer de session. Tapez `$` pour découvrir et invoquer uniquement des compétences. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index d86bcfc..4bd6f1f 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ Fichiers courants : | `.credentials.yml` | Identifiants LLM | | `.cloud-credentials.yml` | Identifiants du fournisseur cloud | | `settings.yml` | Fournisseur sélectionné, modèle et paramètres associés | +| `IAC-CODE.md` | Mémoire utilisateur chargée comme instructions persistantes | | history files | Historique de saisie pour les flux de travail interactifs | Évitez de commiter ou de partager les fichiers de ce répertoire car ils peuvent contenir des secrets ou des préférences locales. +## Fichiers mémoire + +IaC Code possède deux emplacements mémoire publics : + +| Emplacement | Objectif | +|---|---| +| `/IAC-CODE.md` | Mémoire de projet. Elle peut être commitée lorsque les instructions sont utiles à toutes les personnes travaillant sur le projet. | +| `/IAC-CODE.md` | Mémoire utilisateur. Elle suit `IAC_CODE_CONFIG_DIR` et reste privée pour l'utilisateur local. | + +Les fichiers de sujets auto-memory du projet sont stockés sous : + +```text +/projects//memory/ +``` + +`MEMORY.md` dans ce dossier est l'index des sujets. Lorsque auto-memory est activé, IaC Code peut utiliser un side call pour sélectionner les fichiers de sujets pertinents et les ajouter comme contexte de conversation masqué. + ## Paramètres du projet En plus du fichier `~/.iac-code/settings.yml` au niveau utilisateur, IaC Code charge les paramètres au niveau projet depuis le répertoire de travail courant : diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md index 03c7e68..868c881 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ description: 組み込み対話コマンドの完全リファレンス。 | `/effort [level]` | 選択したモデルがエフォート制御をサポートしている場合、アクティブモデルの思考エフォートを表示または変更します。レベルを指定すると、モデルに対して有効な値であれば適用します。レベルなしでは REPL で対話ピッカーを開くか、非対話コンテキストでは現在のエフォートを表示します。 | | `/exit` | 対話 REPL を終了します。エイリアス:`/quit`、`/q`。 | | `/help` | REPL 内で利用可能なコマンドと一般的なキーボードショートカットを表示します。エイリアス:`/?`。 | -| `/memory [<名前>\|search <クエリ>\|delete <名前>\|help]` | 保存済みメモリの一覧表示、表示、検索、削除を行います。自然言語でのメモリ作成は、何かを覚えるよう依頼したときに、引き続きアシスタントがメモリツールを通じて処理します。 | +| `/memory` | メモリセレクターを開きます。プロジェクトまたはユーザーの `IAC-CODE.md` ファイルを編集し、auto-memory を切り替え、auto-memory がオンのときはプロジェクトの auto-memory フォルダーを開けます。 | | `/model [model_name]` | アクティブなモデルを表示または切り替えます。`model_name` を指定すると、アクティブプロバイダーのそのモデルに直接切り替えます。引数なしでは、プロバイダーが設定されている場合は対話モデルピッカーを開き、コンソール UI が利用できない場合は現在のモデルを表示します。 | | `/rename <名前>` | 現在のセッションに名前を付けます。名前はウェルカムバナー、終了時のヒント、`/resume` ピッカーに表示され、一意にセッションを識別できる場合は `/resume` または `--resume` で使用できます。 | | `/resume [セッションID\|一意なIDプレフィックス\|一意なセッション名]` | 以前のセッションを再開します。引数を指定すると、IaC Code は正確なセッション ID、一意な ID プレフィックス、または一意なセッション名として解決します。引数なしでは対話セッションピッカーを開きます。プロジェクト間のセッションでは、現在のプロジェクトをその場で切り替えず、`cd ... && iac-code --resume ` コマンドを表示します。 | | `/skills` | スキル管理ピッカーを開きます。名前や説明でスキルを検索し、名前/ソース/サイズで並べ替え、ユーザーまたはプロジェクトのスキルを有効化/無効化できます。バンドル済みスキルは有効なままロックされます。 | -| `/status` | 現在のセッション ID、プロバイダー、モデル、Alibaba Cloud リージョン、作業ディレクトリ、記録された API トークン使用量、ターン数、コンテキスト使用率を表示します。 | +| `/status` | 現在のセッション ID、プロバイダー、モデル、Alibaba Cloud リージョン、作業ディレクトリ、記録された API トークン使用量、ターン数、コンテキスト使用率を表示します。debug モードでは、メモリリコール side call の回数とトークン使用量も表示します。 | 正確なコマンドリストはリリースによって変わる可能性があります。インストールされたバージョンで利用可能なコマンドを確認するには `/help` を使用するか、REPL で `/` を入力してください。 + +## メモリ + +`/memory` を使うと、IaC Code が会話に読み込むメモリファイルを編集できます。 + +- プロジェクトメモリはプロジェクトルートの `IAC-CODE.md` に保存されます。 +- ユーザーメモリはランタイム設定ディレクトリ内の `IAC-CODE.md` に保存されます。既定では `~/.iac-code/` です。 +- エディターはコンパクトな Vim 風の全画面エディターです。`i`、`a`、`o` で挿入モードに入り、`Esc` で通常モードに戻り、`:wq` で保存、`:q!` で破棄します。 +- `Auto-memory` 行は `Enter` で切り替えられます。auto-memory がオンの場合、IaC Code は関連するプロジェクトのトピックメモリを隠し会話コンテキストとして呼び出せます。 +- auto-memory フォルダーのオプションは、auto-memory がオンのときだけ表示されます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index c3a7837..cccbba2 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## コマンド -`/` を入力すると、利用可能なスラッシュコマンドを確認できます。よく使う運用コマンドには、現在のセッション状態を表示する `/status`、スキル管理の `/skills`、保存済みメモリの `/memory`、アクティブなセッションに名前を付ける `/rename`、セッションを切り替える `/resume` があります。 +`/` を入力すると、利用可能なスラッシュコマンドを確認できます。よく使う運用コマンドには、現在のセッション状態を表示する `/status`、スキル管理の `/skills`、プロジェクトとユーザーのメモリファイルを扱う `/memory`、アクティブなセッションに名前を付ける `/rename`、セッションを切り替える `/resume` があります。 `$` を入力すると、スキルだけを検索して呼び出せます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 14c2d64..f06f85e 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ CLI 引数 > 環境変数 > 設定ファイル | `.credentials.yml` | LLM 認証情報 | | `.cloud-credentials.yml` | クラウドプロバイダー認証情報 | | `settings.yml` | 選択されたプロバイダー、モデル、および関連設定 | +| `IAC-CODE.md` | 永続的な指示として読み込まれるユーザーメモリ | | history files | 対話ワークフローの入力履歴 | このディレクトリのファイルにはシークレットやローカル設定が含まれる場合があるため、コミットや共有は避けてください。 +## メモリファイル + +IaC Code には公開されているメモリ場所が 2 つあります。 + +| 場所 | 用途 | +|---|---| +| `/IAC-CODE.md` | プロジェクトメモリ。プロジェクトで作業する全員に役立つ指示であれば、コミットできます。 | +| `/IAC-CODE.md` | ユーザーメモリ。`IAC_CODE_CONFIG_DIR` に従い、ローカルユーザー専用です。 | + +プロジェクトの auto-memory トピックファイルは以下に保存されます。 + +```text +/projects//memory/ +``` + +そのフォルダー内の `MEMORY.md` はトピックインデックスです。auto-memory がオンの場合、IaC Code は side call を使って関連するトピックファイルを選択し、隠し会話コンテキストとして追加できます。 + ## プロジェクト設定 ユーザーレベルの `~/.iac-code/settings.yml` に加えて、IaC Code は現在の作業ディレクトリからプロジェクトレベルの設定を読み込みます: diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md index 33d9541..036850a 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ O texto apos o nome do comando e passado como argumentos. Na tabela abaixo, `\|search \|delete \|help]` | Listar, ver, pesquisar ou excluir memórias salvas. A criação de memórias em linguagem natural continua sendo feita pelo assistente por meio da ferramenta de memória quando você pede que ele se lembre de algo. | +| `/memory` | Abre o seletor de memória. Edite os arquivos `IAC-CODE.md` de projeto ou usuário, ative ou desative auto-memory e abra a pasta de auto-memory do projeto quando auto-memory estiver ativada. | | `/model [model_name]` | Mostra ou troca o modelo ativo. Com `model_name`, troca diretamente para esse modelo no provedor ativo. Sem argumento, abre um seletor interativo de modelos quando um provedor esta configurado, ou imprime o modelo atual quando nao ha UI de console disponivel. | | `/rename ` | Nomear a sessão atual. Os nomes aparecem no banner de boas-vindas, na dica de saída e no seletor de `/resume`, e podem ser usados com `/resume` ou `--resume` quando identificam uma sessão de forma única. | | `/resume [id-da-sessao\|prefixo-unico-de-id\|nome-unico-da-sessao]` | Retomar uma sessão anterior. Com um argumento, o IaC Code resolve-o como ID exato, prefixo único de ID ou nome único de sessão. Sem argumento, abre o seletor interativo de sessões. Sessões de outros projetos imprimem um comando `cd ... && iac-code --resume ` em vez de trocar o projeto atual em tempo real. | | `/skills` | Abrir o seletor de gerenciamento de habilidades. Pesquise por nome ou descrição, ordene por nome/origem/tamanho e ative ou desative habilidades de usuário ou de projeto. Habilidades integradas permanecem bloqueadas e ativadas. | -| `/status` | Mostrar o ID da sessão atual, provedor, modelo, região da Alibaba Cloud, diretório de trabalho, uso registrado de tokens de API, contagem de turnos e utilização do contexto. | +| `/status` | Mostrar o ID da sessão atual, provedor, modelo, região da Alibaba Cloud, diretório de trabalho, uso registrado de tokens de API, contagem de turnos e utilização do contexto. No modo debug, também mostra contagens de side calls de memória e uso de tokens. | A lista exata de comandos pode mudar entre versoes. Use `/help` ou digite `/` no REPL para inspecionar os comandos disponiveis na sua versao instalada. + +## Memória + +Use `/memory` para editar os arquivos de memória que o IaC Code carrega na conversa: + +- A memória do projeto é salva em `IAC-CODE.md` na raiz do projeto. +- A memória do usuário é salva em `IAC-CODE.md` no diretório de configuração em tempo de execução, `~/.iac-code/` por padrão. +- O editor é um editor compacto em tela cheia no estilo Vim. Use `i`, `a` ou `o` para entrar no modo de inserção, `Esc` para voltar ao modo normal, `:wq` para salvar e `:q!` para descartar. +- A linha `Auto-memory` pode ser alternada com `Enter`. Quando auto-memory está ativada, o IaC Code pode recuperar memórias de tópicos do projeto como contexto oculto da conversa. +- A opção da pasta de auto-memory só aparece quando auto-memory está ativada. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 3d6a544..f5e332e 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ Create a VPC, two ECS instances, and a security group that allows SSH from my of ## Comandos -Digite `/` para descobrir os comandos slash disponíveis. Comandos operacionais comuns incluem `/status` para o estado da sessão atual, `/skills` para gerenciamento de habilidades, `/memory` para memórias salvas, `/rename` para nomear a sessão ativa e `/resume` para alternar sessões. +Digite `/` para descobrir os comandos slash disponíveis. Comandos operacionais comuns incluem `/status` para o estado da sessão atual, `/skills` para gerenciamento de habilidades, `/memory` para arquivos de memória de projeto e usuário, `/rename` para nomear a sessão ativa e `/resume` para alternar sessões. Digite `$` para descobrir e invocar apenas habilidades. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 8e68c99..503dcf4 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ Arquivos comuns: | `.credentials.yml` | Credenciais LLM | | `.cloud-credentials.yml` | Credenciais do provedor de nuvem | | `settings.yml` | Provedor selecionado, modelo e configurações relacionadas | +| `IAC-CODE.md` | Memória do usuário carregada como instruções persistentes | | history files | Histórico de entrada para fluxos de trabalho interativos | Evite fazer commit ou compartilhar arquivos deste diretório porque eles podem conter segredos ou preferências locais. +## Arquivos de memória + +O IaC Code tem dois locais públicos de memória: + +| Local | Finalidade | +|---|---| +| `/IAC-CODE.md` | Memória do projeto. Pode ser commitada quando as instruções forem úteis para todas as pessoas que trabalham no projeto. | +| `/IAC-CODE.md` | Memória do usuário. Segue `IAC_CODE_CONFIG_DIR` e é privada para o usuário local. | + +Os arquivos de tópicos de auto-memory do projeto são armazenados em: + +```text +/projects//memory/ +``` + +`MEMORY.md` nessa pasta é o índice de tópicos. Quando auto-memory está ativada, o IaC Code pode usar uma side call para selecionar arquivos de tópicos relevantes e adicioná-los como contexto oculto da conversa. + ## Configurações do projeto Além do `~/.iac-code/settings.yml` no nível do usuário, o IaC Code carrega configurações no nível do projeto a partir do diretório de trabalho atual: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md index 8816306..d84befd 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,11 +20,21 @@ Slash 命令用于在交互式会话中控制 IaC Code。输入 `/` 可以查看 | `/effort [level]` | 在当前模型支持 effort 控制时,查看或切换 thinking effort。带 `level` 时,如果该值对当前模型有效就会直接应用;不带参数时,在 REPL 中打开交互式选择器,在无控制台 UI 的场景中显示当前 effort。 | | `/exit` | 退出交互式 REPL。别名:`/quit`、`/q`。 | | `/help` | 在 REPL 中显示可用命令和常用快捷键。别名:`/?`。 | -| `/memory [\|search \|delete \|help]` | 列出、查看、搜索或删除已保存的记忆。当你让助手记住某件事时,自然语言创建记忆仍由助手通过 memory 工具完成。 | +| `/memory` | 打开记忆选择器。可以编辑项目或用户的 `IAC-CODE.md` 文件,切换 auto-memory,并在 auto-memory 开启时打开项目 auto-memory 文件夹。 | | `/model [model_name]` | 查看或切换当前模型。带 `model_name` 时,会直接为当前提供商切换到该模型;不带参数时,如果已配置提供商,会打开交互式模型选择器;在无控制台 UI 的场景中会显示当前模型。 | | `/rename ` | 为当前会话命名。名称会显示在欢迎横幅、退出提示和 `/resume` 选择器中;当它能唯一标识一个会话时,也可以用于 `/resume` 或 `--resume`。 | | `/resume [session id\|unique id prefix\|unique session name]` | 恢复历史会话。带参数时,IaC Code 会把它解析为精确会话 ID、唯一 ID 前缀或唯一会话名称;不带参数时打开交互式会话选择器。跨项目会话不会直接热切换,而是打印 `cd ... && iac-code --resume ` 命令。 | | `/skills` | 打开技能管理选择器。可以按名称或描述搜索技能,按名称/来源/大小排序,并启用或禁用用户技能和项目技能。内置技能始终锁定为启用。 | -| `/status` | 显示当前会话 ID、提供商、模型、阿里云地域、工作目录、已记录的 API token 用量、轮次数和上下文利用率。 | +| `/status` | 显示当前会话 ID、提供商、模型、阿里云地域、工作目录、已记录的 API token 用量、轮次数和上下文利用率。debug 模式下还会显示记忆召回 side call 次数和 token 用量。 | 准确命令列表可能随版本变化。请在 REPL 中使用 `/help` 或输入 `/` 查看当前安装版本支持的命令。 + +## 记忆 + +使用 `/memory` 可以编辑 IaC Code 加载到对话中的记忆文件: + +- 项目记忆保存在项目根目录的 `IAC-CODE.md`。 +- 用户记忆保存在运行时配置目录中的 `IAC-CODE.md`,默认位于 `~/.iac-code/`。 +- 编辑器是一个轻量的 Vim-like 全屏编辑器。使用 `i`、`a` 或 `o` 进入插入模式,`Esc` 回到普通模式,`:wq` 保存,`:q!` 放弃修改。 +- `Auto-memory` 行可以按 `Enter` 切换。开启后,IaC Code 可以把相关的项目 topic memory 作为隐藏会话上下文召回。 +- auto-memory 文件夹选项只会在 auto-memory 开启时显示。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md index 3b001f4..8026efc 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/interactive-mode.md @@ -27,7 +27,7 @@ iac-code ## 命令 -输入 `/` 可以发现可用的 Slash 命令。常用运维命令包括:用 `/status` 查看当前会话状态,用 `/skills` 管理技能,用 `/memory` 查看已保存记忆,用 `/rename` 命名当前会话,以及用 `/resume` 切换会话。 +输入 `/` 可以发现可用的 Slash 命令。常用运维命令包括:用 `/status` 查看当前会话状态,用 `/skills` 管理技能,用 `/memory` 编辑项目和用户记忆文件,用 `/rename` 命名当前会话,以及用 `/resume` 切换会话。 输入 `$` 只会发现并调用技能。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 571f398..20c3e56 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,10 +28,28 @@ CLI 参数 > 环境变量 > 配置文件 | `.credentials.yml` | LLM 凭证 | | `.cloud-credentials.yml` | 云厂商凭证 | | `settings.yml` | 已选择的提供商、模型和相关设置 | +| `IAC-CODE.md` | 作为持久指令加载的用户记忆 | | history files | 交互式工作流的输入历史 | 避免提交或分享该目录中的文件,因为它们可能包含密钥或本地偏好。 +## 记忆文件 + +IaC Code 有两个公开的记忆位置: + +| 位置 | 用途 | +|---|---| +| `/IAC-CODE.md` | 项目记忆。当这些指令对项目协作者都有用时,可以提交到版本库。 | +| `/IAC-CODE.md` | 用户记忆。它跟随 `IAC_CODE_CONFIG_DIR`,只属于本地用户。 | + +项目 auto-memory topic 文件存放在: + +```text +/projects//memory/ +``` + +该文件夹中的 `MEMORY.md` 是 topic 索引。auto-memory 开启时,IaC Code 可能通过 side call 选择相关 topic 文件,并把它们作为隐藏会话上下文加入对话。 + ## 项目级设置 除了用户级的 `~/.iac-code/settings.yml`,IaC Code 还会从当前工作目录加载项目级设置: From 1033c5f79bc438f22611ca708a94f5e3f270b2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 8 Jun 2026 15:13:33 +0800 Subject: [PATCH 2/4] fix: align instruction memory prompt behavior --- Makefile | 4 +- src/iac_code/agent/system_prompt.py | 13 ++---- src/iac_code/commands/prompt.py | 1 - .../i18n/locales/de/LC_MESSAGES/messages.po | 7 ++-- .../i18n/locales/es/LC_MESSAGES/messages.po | 7 ++-- .../i18n/locales/fr/LC_MESSAGES/messages.po | 7 ++-- .../i18n/locales/ja/LC_MESSAGES/messages.po | 7 ++-- .../i18n/locales/pt/LC_MESSAGES/messages.po | 7 ++-- .../i18n/locales/zh/LC_MESSAGES/messages.po | 7 ++-- src/iac_code/memory/project_memory.py | 42 +++++++++++++------ src/iac_code/ui/dialogs/memory_editor.py | 2 +- tests/agent/test_system_prompt.py | 26 +++++++++--- tests/memory/test_project_memory.py | 31 ++++++++++---- tests/services/test_agent_factory.py | 6 +-- website/docs/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- .../current/cli/commands.md | 7 ++-- .../configuration/runtime-configuration.md | 10 +++-- 28 files changed, 171 insertions(+), 115 deletions(-) diff --git a/Makefile b/Makefile index 3f1612c..c5d495c 100644 --- a/Makefile +++ b/Makefile @@ -64,10 +64,10 @@ translate: ## Extract, update and compile translations done run: ## Run iac-code - uv run iac-code + IAC_CODE_INSTRUCTION_MEMORY_FILE=IAC-CODE.md uv run iac-code dev: ## Run iac-code in debug mode - uv run iac-code --debug + IAC_CODE_INSTRUCTION_MEMORY_FILE=IAC-CODE.md uv run iac-code --debug clean: ## Clean build artifacts rm -rf .ruff_cache .pytest_cache dist build htmlcov .coverage coverage.xml diff --git a/src/iac_code/agent/system_prompt.py b/src/iac_code/agent/system_prompt.py index 9206a22..049bf6a 100644 --- a/src/iac_code/agent/system_prompt.py +++ b/src/iac_code/agent/system_prompt.py @@ -208,13 +208,11 @@ def _build_actions_section() -> str: def _build_project_instructions(cwd: str) -> str: - from iac_code import __release_date__ - - if not __release_date__.strip(): - return "" + from iac_code.memory.project_memory import get_instruction_memory_file_name + instruction_memory_file = get_instruction_memory_file_name() instructions: list[str] = [] - search_names = ["AGENTS.md", ".iac-code/AGENTS.md"] + search_names = [instruction_memory_file, os.path.join(".iac-code", instruction_memory_file)] current = os.path.abspath(cwd) from iac_code.utils.project_paths import find_git_worktree_root @@ -252,13 +250,10 @@ def _build_memory_section(memory_content: str) -> str: def _build_memory_context_section(memory_context: object) -> str: parts: list[str] = [] instruction_memory = str(getattr(memory_context, "instruction_memory_content", "") or "").strip() - memory_index = str(getattr(memory_context, "memory_index_content", "") or "").strip() memory_mechanics = str(getattr(memory_context, "memory_mechanics_content", "") or "").strip() if instruction_memory: parts.append(f"## Instruction Memory\n{instruction_memory}") - if memory_index: - parts.append(f"## Project Memory Index\n{memory_index}") if memory_mechanics: parts.append(f"## Memory Mechanics\n{memory_mechanics}") if not parts: @@ -323,7 +318,7 @@ def build_system_prompt( is_static=False, ) - project_instructions = _build_project_instructions(cwd) + project_instructions = "" if memory_context is not None else _build_project_instructions(cwd) if project_instructions: builder.add_cached_section( "project_instructions", diff --git a/src/iac_code/commands/prompt.py b/src/iac_code/commands/prompt.py index 22fcd59..a1ccb3a 100644 --- a/src/iac_code/commands/prompt.py +++ b/src/iac_code/commands/prompt.py @@ -444,7 +444,6 @@ def _memory_sections(repl: object) -> list[dict[str, str]]: sections: list[dict[str, str]] = [] for title, attr in [ (_("Instruction Memory"), "instruction_memory_content"), - (_("Project Memory Index"), "memory_index_content"), (_("Memory Mechanics"), "memory_mechanics_content"), ]: content = str(getattr(memory_context, attr, "") or "").strip() diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 32d321d..b1befc3 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -1439,10 +1439,6 @@ msgstr "Tools" msgid "Instruction Memory" msgstr "Anweisungsspeicher" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "Projektspeicherindex" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "Speichermechanik" @@ -3477,3 +3473,6 @@ msgstr "" #~ msgid "{n} day{s} ago" #~ msgstr "vor {n} Tag{s}" +#~ msgid "Project Memory Index" +#~ msgstr "Projektspeicherindex" + diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index ea374ae..14954d0 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -1439,10 +1439,6 @@ msgstr "Herramientas" msgid "Instruction Memory" msgstr "Memoria de instrucciones" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "Índice de memoria del proyecto" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "Mecánica de memoria" @@ -3485,3 +3481,6 @@ msgstr "" #~ msgid "{n} day{s} ago" #~ msgstr "hace {n} día{s}" +#~ msgid "Project Memory Index" +#~ msgstr "Índice de memoria del proyecto" + diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 71c466c..0f17365 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -1444,10 +1444,6 @@ msgstr "Outils" msgid "Instruction Memory" msgstr "Mémoire d’instructions" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "Index de mémoire du projet" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "Mécanique de mémoire" @@ -3475,3 +3471,6 @@ msgstr "" #~ msgid "{n} day{s} ago" #~ msgstr "il y a {n} jour{s}" +#~ msgid "Project Memory Index" +#~ msgstr "Index de mémoire du projet" + diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 63151be..3213bbe 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -1405,10 +1405,6 @@ msgstr "ツール" msgid "Instruction Memory" msgstr "指示メモリ" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "プロジェクトメモリ索引" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "メモリの仕組み" @@ -3372,3 +3368,6 @@ msgstr " オプション 2 - github.com にアクセスできない場合は、 #~ msgid "{n} day{s} ago" #~ msgstr "{n} 日{s}前" +#~ msgid "Project Memory Index" +#~ msgstr "プロジェクトメモリ索引" + diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 4ba9a0e..f2a6089 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -1433,10 +1433,6 @@ msgstr "Ferramentas" msgid "Instruction Memory" msgstr "Memória de instruções" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "Índice de memória do projeto" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "Mecânica da memória" @@ -3448,3 +3444,6 @@ msgstr "" #~ msgid "{n} day{s} ago" #~ msgstr "há {n} dia{s}" +#~ msgid "Project Memory Index" +#~ msgstr "Índice de memória do projeto" + diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 820874a..5c10b9e 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -1393,10 +1393,6 @@ msgstr "工具" msgid "Instruction Memory" msgstr "指令记忆" -#: src/iac_code/commands/prompt.py -msgid "Project Memory Index" -msgstr "项目记忆索引" - #: src/iac_code/commands/prompt.py msgid "Memory Mechanics" msgstr "记忆机制" @@ -3340,3 +3336,6 @@ msgstr " 方式 2 - 如果无法访问 github.com,运行以下命令通过 np #~ msgid "{n} day{s} ago" #~ msgstr "{n} 天前" +#~ msgid "Project Memory Index" +#~ msgstr "项目记忆索引" + diff --git a/src/iac_code/memory/project_memory.py b/src/iac_code/memory/project_memory.py index 4555a45..bc0d30a 100644 --- a/src/iac_code/memory/project_memory.py +++ b/src/iac_code/memory/project_memory.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from dataclasses import dataclass from pathlib import Path @@ -10,7 +11,9 @@ from iac_code.utils.file_security import ensure_private_dir from iac_code.utils.project_paths import find_git_worktree_root, sanitize_path -INSTRUCTION_MEMORY_FILE = "IAC-CODE.md" +DEFAULT_INSTRUCTION_MEMORY_FILE = "AGENTS.md" +INSTRUCTION_MEMORY_FILE = DEFAULT_INSTRUCTION_MEMORY_FILE +INSTRUCTION_MEMORY_FILE_ENV = "IAC_CODE_INSTRUCTION_MEMORY_FILE" _MEMORY_SETTINGS_KEY = "memory" _AUTO_MEMORY_SETTINGS_KEY = "autoMemory" @@ -25,7 +28,6 @@ def has_content(self) -> bool: return any( ( self.instruction_memory_content.strip(), - self.memory_index_content.strip(), self.memory_mechanics_content.strip(), ) ) @@ -49,8 +51,9 @@ def get_project_memory_dir(cwd: str) -> Path: class ProjectMemoryRuntime: def __init__(self, cwd: str): self.project_root = resolve_project_root(cwd) - self.user_instruction_path = get_config_dir() / INSTRUCTION_MEMORY_FILE - self.project_instruction_path = self.project_root / INSTRUCTION_MEMORY_FILE + self.instruction_memory_file = get_instruction_memory_file_name() + self.user_instruction_path = get_config_dir() / self.instruction_memory_file + self.project_instruction_path = self.project_root / self.instruction_memory_file self.auto_memory_dir = get_project_memory_dir(cwd) self.memory_manager = MemoryManager(memory_dir=str(self.auto_memory_dir)) @@ -67,18 +70,19 @@ def ensure_auto_memory_dir(self) -> Path: def build_memory_context(self) -> MemoryContext: instruction_content = self._build_instruction_memory_content() - index_content = self.memory_manager.get_index_content().strip() return MemoryContext( instruction_memory_content=instruction_content, - memory_index_content=index_content, - memory_mechanics_content=_memory_mechanics_content(is_auto_memory_enabled()), + memory_mechanics_content=_memory_mechanics_content( + is_auto_memory_enabled(), + instruction_memory_file=self.instruction_memory_file, + ), ) def _build_instruction_memory_content(self) -> str: parts: list[str] = [] for label, path in ( - ("User IAC-CODE.md", self.user_instruction_path), - ("Project IAC-CODE.md", self.project_instruction_path), + (f"User {self.instruction_memory_file}", self.user_instruction_path), + (f"Project {self.instruction_memory_file}", self.project_instruction_path), ): content = _read_text_if_present(path) if content: @@ -86,6 +90,17 @@ def _build_instruction_memory_content(self) -> str: return "\n\n".join(parts) +def get_instruction_memory_file_name() -> str: + configured = os.environ.get(INSTRUCTION_MEMORY_FILE_ENV, "").strip() + if not configured: + return DEFAULT_INSTRUCTION_MEMORY_FILE + if "/" in configured or "\\" in configured: + return DEFAULT_INSTRUCTION_MEMORY_FILE + if configured in {"", ".", ".."}: + return DEFAULT_INSTRUCTION_MEMORY_FILE + return configured + + def _read_text_if_present(path: Path) -> str: try: if path.is_symlink() or not path.is_file(): @@ -114,7 +129,7 @@ def save_auto_memory_enabled(enabled: bool) -> None: _save_yaml(get_settings_path(), settings) -def _memory_mechanics_content(auto_memory_enabled: bool) -> str: +def _memory_mechanics_content(auto_memory_enabled: bool, *, instruction_memory_file: str) -> str: auto_memory_line = ( "- Auto-memory is on; topic memories may be recalled and updated when the user asks." if auto_memory_enabled @@ -123,10 +138,11 @@ def _memory_mechanics_content(auto_memory_enabled: bool) -> str: return "\n".join( [ "Use memory carefully:", - "- IAC-CODE.md files contain always-on user and project instructions.", - "- MEMORY.md is an always-on index of project topic memories.", + f"- {instruction_memory_file} files contain always-on user and project instructions.", + "- MEMORY.md is the project auto-memory topic index; it is used by side recall and is not always injected.", auto_memory_line, - "- Topic files are not always injected; use read_memory to inspect relevant topics.", + "- Topic files are selected by side recall and injected as hidden conversation context when relevant.", + "- Use read_memory to inspect relevant topics that were not automatically recalled.", "- Use write_memory only when the user explicitly asks to remember or preserve information.", "- Treat recalled memories as potentially stale and verify before relying on time-sensitive facts.", ] diff --git a/src/iac_code/ui/dialogs/memory_editor.py b/src/iac_code/ui/dialogs/memory_editor.py index 81be667..a1c0cf1 100644 --- a/src/iac_code/ui/dialogs/memory_editor.py +++ b/src/iac_code/ui/dialogs/memory_editor.py @@ -1,4 +1,4 @@ -"""Small Vim-like editor for IAC-CODE.md memory files.""" +"""Small Vim-like editor for instruction memory files.""" from __future__ import annotations diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index f8fd686..dec4191 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -113,7 +113,7 @@ def test_memory_section_absent_when_empty(self): memory_lines = [line for line in lines if line.strip().startswith("# Memory")] assert len(memory_lines) == 0 - def test_explicit_memory_context_includes_instruction_index_and_mechanics(self): + def test_explicit_memory_context_excludes_auto_memory_index(self): from iac_code.memory.project_memory import MemoryContext context = MemoryContext( @@ -126,7 +126,8 @@ def test_explicit_memory_context_includes_instruction_index_and_mechanics(self): assert "User instruction" in prompt assert "Project instruction" in prompt - assert "topic-a.md" in prompt + assert "topic-a.md" not in prompt + assert "Project Memory Index" not in prompt assert "read_memory" in prompt assert "Topic body should not be always injected" not in prompt @@ -144,7 +145,7 @@ def test_project_instructions_stop_at_git_worktree_root(self, tmp_path: Path): assert "worktree instructions" in prompt assert "parent instructions" not in prompt - def test_project_instructions_skipped_for_local_build(self, tmp_path: Path, monkeypatch): + def test_project_instructions_loaded_for_local_build(self, tmp_path: Path, monkeypatch): monkeypatch.setattr("iac_code.__release_date__", "") cwd = tmp_path / "repo" cwd.mkdir() @@ -153,8 +154,23 @@ def test_project_instructions_skipped_for_local_build(self, tmp_path: Path, monk prompt = build_system_prompt(cwd=str(cwd)) - assert "local build instructions" not in prompt - assert "# Project Instructions" not in prompt + assert "local build instructions" in prompt + assert "# Project Instructions" in prompt + + def test_project_instructions_not_duplicated_when_memory_context_supplies_them(self, tmp_path: Path): + from iac_code.memory.project_memory import MemoryContext + + cwd = tmp_path / "repo" + cwd.mkdir() + (cwd / ".git").mkdir() + (cwd / "AGENTS.md").write_text("project instructions", encoding="utf-8") + + prompt = build_system_prompt( + cwd=str(cwd), + memory_context=MemoryContext(instruction_memory_content="## Project AGENTS.md\nproject instructions"), + ) + + assert prompt.count("project instructions") == 1 def test_volatile_current_time_stays_out_of_static_cache_prefix(self, monkeypatch): from iac_code.agent import system_prompt diff --git a/tests/memory/test_project_memory.py b/tests/memory/test_project_memory.py index a955592..db5a7d9 100644 --- a/tests/memory/test_project_memory.py +++ b/tests/memory/test_project_memory.py @@ -24,7 +24,7 @@ def test_project_memory_dir_uses_git_root_and_config_dir(tmp_path, monkeypatch): assert memory_dir == config_dir / "projects" / mod.project_key_for_cwd(str(repo)) / "memory" -def test_project_memory_runtime_exposes_instruction_files_and_auto_memory(tmp_path, monkeypatch): +def test_project_memory_runtime_defaults_instruction_files_to_agents_md(tmp_path, monkeypatch): mod = _module() config_dir = tmp_path / "config" project = tmp_path / "project" @@ -33,13 +33,27 @@ def test_project_memory_runtime_exposes_instruction_files_and_auto_memory(tmp_pa runtime = mod.ProjectMemoryRuntime(str(project)) - assert runtime.user_instruction_path == config_dir / "IAC-CODE.md" - assert runtime.project_instruction_path == project / "IAC-CODE.md" + assert runtime.user_instruction_path == config_dir / "AGENTS.md" + assert runtime.project_instruction_path == project / "AGENTS.md" assert runtime.auto_memory_dir == config_dir / "projects" / mod.project_key_for_cwd(str(project)) / "memory" assert runtime.memory_manager._memory_dir == runtime.auto_memory_dir -def test_build_memory_context_reads_iac_code_files_and_memory_index_only(tmp_path, monkeypatch): +def test_project_memory_runtime_allows_instruction_file_env_override(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("IAC_CODE_INSTRUCTION_MEMORY_FILE", "IAC-CODE.md") + + runtime = mod.ProjectMemoryRuntime(str(project)) + + assert runtime.user_instruction_path == config_dir / "IAC-CODE.md" + assert runtime.project_instruction_path == project / "IAC-CODE.md" + + +def test_build_memory_context_reads_instruction_files_without_memory_index(tmp_path, monkeypatch): mod = _module() config_dir = tmp_path / "config" project = tmp_path / "project" @@ -60,8 +74,9 @@ def test_build_memory_context_reads_iac_code_files_and_memory_index_only(tmp_pat assert "User instruction" in context.instruction_memory_content assert "Project instruction" in context.instruction_memory_content - assert "topic-a.md" in context.memory_index_content - assert "Topic body should not be always injected" not in context.memory_index_content + assert context.memory_index_content == "" + assert "topic-a.md" not in context.instruction_memory_content + assert "Topic body should not be always injected" not in context.instruction_memory_content assert "read_memory" in context.memory_mechanics_content assert "write_memory" in context.memory_mechanics_content @@ -76,7 +91,7 @@ def test_ensure_user_instruction_file_returns_path_without_creating_empty_file(t created = runtime.ensure_instruction_file("user") - assert created == config_dir / "IAC-CODE.md" + assert created == config_dir / "AGENTS.md" assert not created.exists() @@ -91,7 +106,7 @@ def test_ensure_project_instruction_file_returns_path_without_creating_empty_fil created = runtime.ensure_instruction_file("project") - assert created == project / "IAC-CODE.md" + assert created == project / "AGENTS.md" assert not created.exists() assert stat.S_IMODE(project.stat().st_mode) == 0o755 diff --git a/tests/services/test_agent_factory.py b/tests/services/test_agent_factory.py index 2792124..e57ce94 100644 --- a/tests/services/test_agent_factory.py +++ b/tests/services/test_agent_factory.py @@ -164,8 +164,8 @@ def test_create_agent_runtime_uses_project_memory_context(tmp_path, monkeypatch) monkeypatch.chdir(project) monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) (config_dir).mkdir() - (config_dir / "IAC-CODE.md").write_text("User memory instruction\n", encoding="utf-8") - (project / "IAC-CODE.md").write_text("Project memory instruction\n", encoding="utf-8") + (config_dir / "AGENTS.md").write_text("User memory instruction\n", encoding="utf-8") + (project / "AGENTS.md").write_text("Project memory instruction\n", encoding="utf-8") topic_manager = MemoryManager(memory_dir=str(get_project_memory_dir(str(project)))) topic_manager.save( "topic-a", @@ -180,7 +180,7 @@ def test_create_agent_runtime_uses_project_memory_context(tmp_path, monkeypatch) assert runtime.agent_loop._memory_recall_service is not None assert "User memory instruction" in runtime.agent_loop.system_prompt assert "Project memory instruction" in runtime.agent_loop.system_prompt - assert "topic-a.md" in runtime.agent_loop.system_prompt + assert "topic-a.md" not in runtime.agent_loop.system_prompt assert "Topic body should not be always injected" not in runtime.agent_loop.system_prompt diff --git a/website/docs/cli/commands.md b/website/docs/cli/commands.md index 6121d5f..ea195dc 100644 --- a/website/docs/cli/commands.md +++ b/website/docs/cli/commands.md @@ -20,7 +20,7 @@ Text after the command name is passed as arguments. In the table below, `` | `/effort [level]` | Show or change thinking effort for the active model when the selected model supports effort control. With a level, it applies the requested value if valid for the model. Without a level, it opens an interactive picker in the REPL, or prints the current effort in non-interactive contexts. | | `/exit` | Exit the interactive REPL. Aliases: `/quit`, `/q`. | | `/help` | Show available commands and common keyboard shortcuts inside the REPL. Alias: `/?`. | -| `/memory` | Open the memory selector. Edit project or user `IAC-CODE.md` files, toggle auto-memory, and open the project auto-memory folder when auto-memory is on. | +| `/memory` | Open the memory selector. Edit project or user `AGENTS.md` files, toggle auto-memory, and open the project auto-memory folder when auto-memory is on. | | `/model [model_name]` | Show or switch the active model. With `model_name`, it switches directly to that model for the active provider. Without an argument, it opens an interactive model picker when a provider is configured, or prints the current model when no console UI is available. | | `/rename ` | Name the current session. Names appear in the welcome banner, exit hint, and `/resume` picker, and can be used with `/resume` or `--resume` when they uniquely identify a session. | | `/resume [session id\|unique id prefix\|unique session name]` | Resume a previous session. With an argument, IaC Code resolves it as an exact session ID, unique ID prefix, or unique session name. Without an argument, it opens the interactive session picker. Cross-project sessions print a `cd ... && iac-code --resume ` command instead of hot-swapping the current project. | @@ -33,8 +33,9 @@ The exact command list can change between releases. Use `/help` or type `/` in t Use `/memory` to edit the memory files IaC Code loads into the conversation: -- Project memory is saved in `IAC-CODE.md` at the project root. -- User memory is saved in `IAC-CODE.md` in the runtime configuration directory, `~/.iac-code/` by default. +- Project memory is saved in `AGENTS.md` at the project root by default. +- User memory is saved in `AGENTS.md` in the runtime configuration directory, `~/.iac-code/` by default. +- Set `IAC_CODE_INSTRUCTION_MEMORY_FILE` to use another filename, for example `IAC-CODE.md`. - The editor is a compact Vim-like full-screen editor. Use `i`, `a`, or `o` to enter insert mode, `Esc` to return to normal mode, `:wq` to save, and `:q!` to discard. - The `Auto-memory` row can be toggled with `Enter`. When auto-memory is on, IaC Code can recall relevant project topic memories as hidden conversation context. - The auto-memory folder option appears only when auto-memory is on. diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index bbe1081..a51ff99 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ Common files: | `.credentials.yml` | LLM credentials | | `.cloud-credentials.yml` | Cloud provider credentials | | `settings.yml` | Selected provider, model, and related settings | -| `IAC-CODE.md` | User memory loaded as persistent instructions | +| `AGENTS.md` | User memory loaded as persistent instructions | | history files | Input history for interactive workflows | Avoid committing or sharing files from this directory because they can contain secrets or local preferences. @@ -39,8 +39,10 @@ IaC Code has two public memory locations: | Location | Purpose | |---|---| -| `/IAC-CODE.md` | Project memory. This can be committed when the instructions are useful for everyone working in the project. | -| `/IAC-CODE.md` | User memory. This follows `IAC_CODE_CONFIG_DIR` and is private to the local user. | +| `/AGENTS.md` | Project memory. This can be committed when the instructions are useful for everyone working in the project. | +| `/AGENTS.md` | User memory. This follows `IAC_CODE_CONFIG_DIR` and is private to the local user. | + +Set `IAC_CODE_INSTRUCTION_MEMORY_FILE` to use another instruction memory filename, for example `IAC-CODE.md`. Project auto-memory topic files are stored under: @@ -48,7 +50,7 @@ Project auto-memory topic files are stored under: /projects//memory/ ``` -`MEMORY.md` in that folder is the topic index. When auto-memory is on, IaC Code may use a side call to select relevant topic files and add them as hidden conversation context. +`MEMORY.md` in that folder is the topic index used by auto-memory side calls. It is not loaded as always-on context. When auto-memory is on, IaC Code may select relevant topic files and add them as hidden conversation context. ## Project Settings diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md index 4c7e5da..43d852b 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ Text nach dem Befehlsnamen wird als Argumente uebergeben. In der folgenden Tabel | `/effort [level]` | Zeigen oder aendern Sie den Denkaufwand fuer das aktive Modell, wenn das ausgewaehlte Modell Aufwandsteuerung unterstuetzt. Mit einem Level wird der angeforderte Wert angewendet, wenn er fuer das Modell gueltig ist. Ohne Level wird im REPL eine interaktive Auswahl geoeffnet, oder der aktuelle Aufwand wird in nicht-interaktiven Kontexten ausgegeben. | | `/exit` | Beenden Sie das interaktive REPL. Aliase: `/quit`, `/q`. | | `/help` | Zeigen Sie verfuegbare Befehle und gaengige Tastenkuerzel im REPL an. Alias: `/?`. | -| `/memory` | Öffnet die Speicherauswahl. Bearbeiten Sie Projekt- oder Benutzerdateien `IAC-CODE.md`, schalten Sie auto-memory ein oder aus und öffnen Sie den auto-memory-Ordner des Projekts, wenn auto-memory aktiviert ist. | +| `/memory` | Öffnet die Speicherauswahl. Bearbeiten Sie Projekt- oder Benutzerdateien `AGENTS.md`, schalten Sie auto-memory ein oder aus und öffnen Sie den auto-memory-Ordner des Projekts, wenn auto-memory aktiviert ist. | | `/model [model_name]` | Zeigen oder wechseln Sie das aktive Modell. Mit `model_name` wird direkt zu diesem Modell fuer den aktiven Anbieter gewechselt. Ohne Argument wird eine interaktive Modellauswahl geoeffnet, wenn ein Anbieter konfiguriert ist, oder das aktuelle Modell wird ausgegeben, wenn keine Konsolen-UI verfuegbar ist. | | `/rename ` | Die aktuelle Sitzung benennen. Namen erscheinen im Willkommensbanner, im Exit-Hinweis und in der `/resume`-Auswahl und können mit `/resume` oder `--resume` verwendet werden, wenn sie eine Sitzung eindeutig identifizieren. | | `/resume [sitzungs-id\|eindeutiges-id-präfix\|eindeutiger-sitzungsname]` | Eine frühere Sitzung fortsetzen. Mit einem Argument löst IaC Code es als exakte Sitzungs-ID, eindeutiges ID-Präfix oder eindeutigen Sitzungsnamen auf. Ohne Argument wird die interaktive Sitzungsauswahl geöffnet. Projektübergreifende Sitzungen geben einen `cd ... && iac-code --resume `-Befehl aus, anstatt das aktuelle Projekt direkt zu wechseln. | @@ -33,8 +33,9 @@ Die genaue Befehlsliste kann sich zwischen Versionen aendern. Verwenden Sie `/he Verwenden Sie `/memory`, um die Speicherdateien zu bearbeiten, die IaC Code in die Unterhaltung lädt: -- Projektspeicher wird in `IAC-CODE.md` im Projektstamm gespeichert. -- Benutzerspeicher wird in `IAC-CODE.md` im Laufzeit-Konfigurationsverzeichnis gespeichert, standardmäßig `~/.iac-code/`. +- Projektspeicher wird standardmäßig in `AGENTS.md` im Projektstamm gespeichert. +- Benutzerspeicher wird in `AGENTS.md` im Laufzeit-Konfigurationsverzeichnis gespeichert, standardmäßig `~/.iac-code/`. +- Setzen Sie `IAC_CODE_INSTRUCTION_MEMORY_FILE`, um einen anderen Dateinamen zu verwenden, zum Beispiel `IAC-CODE.md`. - Der Editor ist ein kompakter Vollbild-Editor im Vim-Stil. Verwenden Sie `i`, `a` oder `o`, um in den Einfügemodus zu wechseln, `Esc`, um zum Normalmodus zurückzukehren, `:wq` zum Speichern und `:q!` zum Verwerfen. - Die Zeile `Auto-memory` kann mit `Enter` umgeschaltet werden. Wenn auto-memory aktiviert ist, kann IaC Code relevante Projektthemen-Speicher als versteckten Unterhaltungskontext abrufen. - Die Option für den auto-memory-Ordner erscheint nur, wenn auto-memory aktiviert ist. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 06ea878..21ee0d4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ Häufige Dateien: | `.credentials.yml` | LLM-Anmeldedaten | | `.cloud-credentials.yml` | Cloud-Anbieter-Anmeldedaten | | `settings.yml` | Ausgewählter Anbieter, Modell und zugehörige Einstellungen | -| `IAC-CODE.md` | Benutzerspeicher, der als dauerhafte Anweisungen geladen wird | +| `AGENTS.md` | Benutzerspeicher, der als dauerhafte Anweisungen geladen wird | | Verlaufsdateien | Eingabeverlauf für interaktive Workflows | Vermeiden Sie es, Dateien aus diesem Verzeichnis zu committen oder zu teilen, da sie Geheimnisse oder lokale Einstellungen enthalten können. @@ -39,8 +39,10 @@ IaC Code hat zwei öffentliche Speicherorte: | Speicherort | Zweck | |---|---| -| `/IAC-CODE.md` | Projektspeicher. Er kann committet werden, wenn die Anweisungen für alle Personen nützlich sind, die am Projekt arbeiten. | -| `/IAC-CODE.md` | Benutzerspeicher. Er folgt `IAC_CODE_CONFIG_DIR` und ist privat für den lokalen Benutzer. | +| `/AGENTS.md` | Projektspeicher. Er kann committet werden, wenn die Anweisungen für alle Personen nützlich sind, die am Projekt arbeiten. | +| `/AGENTS.md` | Benutzerspeicher. Er folgt `IAC_CODE_CONFIG_DIR` und ist privat für den lokalen Benutzer. | + +Setzen Sie `IAC_CODE_INSTRUCTION_MEMORY_FILE`, um einen anderen Dateinamen für den Anweisungsspeicher zu verwenden, zum Beispiel `IAC-CODE.md`. Projektbezogene auto-memory-Themendateien werden hier gespeichert: @@ -48,7 +50,7 @@ Projektbezogene auto-memory-Themendateien werden hier gespeichert: /projects//memory/ ``` -`MEMORY.md` in diesem Ordner ist der Themenindex. Wenn auto-memory aktiviert ist, kann IaC Code mit einem Side Call relevante Themendateien auswählen und sie als versteckten Unterhaltungskontext hinzufügen. +`MEMORY.md` in diesem Ordner ist der Themenindex, den auto-memory-Side-Calls verwenden. Er wird nicht als dauerhafter Kontext geladen. Wenn auto-memory aktiviert ist, kann IaC Code relevante Themendateien auswählen und sie als versteckten Unterhaltungskontext hinzufügen. ## Projekteinstellungen diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md index fd0b80d..cecb42c 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ El texto despues del nombre del comando se pasa como argumentos. En la tabla sig | `/effort [level]` | Muestra o cambia el esfuerzo de pensamiento para el modelo activo cuando el modelo seleccionado admite control de esfuerzo. Con un nivel, aplica el valor solicitado si es valido para el modelo. Sin un nivel, abre un selector interactivo en el REPL, o imprime el esfuerzo actual en contextos no interactivos. | | `/exit` | Sale del REPL interactivo. Alias: `/quit`, `/q`. | | `/help` | Muestra los comandos disponibles y los atajos de teclado comunes dentro del REPL. Alias: `/?`. | -| `/memory` | Abre el selector de memoria. Edita los archivos `IAC-CODE.md` de proyecto o usuario, activa o desactiva auto-memory y abre la carpeta de auto-memory del proyecto cuando auto-memory está activada. | +| `/memory` | Abre el selector de memoria. Edita los archivos `AGENTS.md` de proyecto o usuario, activa o desactiva auto-memory y abre la carpeta de auto-memory del proyecto cuando auto-memory está activada. | | `/model [model_name]` | Muestra o cambia el modelo activo. Con `model_name`, cambia directamente a ese modelo para el proveedor activo. Sin argumento, abre un selector interactivo de modelos cuando hay un proveedor configurado, o imprime el modelo actual cuando no hay interfaz de consola disponible. | | `/rename ` | Nombrar la sesión actual. Los nombres aparecen en el banner de bienvenida, en la sugerencia de salida y en el selector de `/resume`, y pueden usarse con `/resume` o `--resume` cuando identifican una sesión de forma única. | | `/resume [id-de-sesion\|prefijo-unico-de-id\|nombre-unico-de-sesion]` | Reanudar una sesión anterior. Con un argumento, IaC Code lo resuelve como ID exacto, prefijo único de ID o nombre único de sesión. Sin argumento, abre el selector interactivo de sesiones. Las sesiones de otros proyectos imprimen un comando `cd ... && iac-code --resume ` en lugar de cambiar en caliente el proyecto actual. | @@ -33,8 +33,9 @@ La lista exacta de comandos puede cambiar entre versiones. Usa `/help` o escribe Usa `/memory` para editar los archivos de memoria que IaC Code carga en la conversación: -- La memoria del proyecto se guarda en `IAC-CODE.md` en la raíz del proyecto. -- La memoria de usuario se guarda en `IAC-CODE.md` dentro del directorio de configuración en tiempo de ejecución, `~/.iac-code/` de forma predeterminada. +- La memoria del proyecto se guarda en `AGENTS.md` en la raíz del proyecto de forma predeterminada. +- La memoria de usuario se guarda en `AGENTS.md` dentro del directorio de configuración en tiempo de ejecución, `~/.iac-code/` de forma predeterminada. +- Defina `IAC_CODE_INSTRUCTION_MEMORY_FILE` para usar otro nombre de archivo, por ejemplo `IAC-CODE.md`. - El editor es un editor compacto de pantalla completa similar a Vim. Usa `i`, `a` u `o` para entrar en modo de inserción, `Esc` para volver al modo normal, `:wq` para guardar y `:q!` para descartar. - La fila `Auto-memory` se puede alternar con `Enter`. Cuando auto-memory está activada, IaC Code puede recuperar memorias de temas del proyecto como contexto de conversación oculto. - La opción de carpeta de auto-memory solo aparece cuando auto-memory está activada. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 9880d8d..6f143b4 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ Archivos comunes: | `.credentials.yml` | Credenciales de LLM | | `.cloud-credentials.yml` | Credenciales del proveedor de nube | | `settings.yml` | Proveedor seleccionado, modelo y configuraciones relacionadas | -| `IAC-CODE.md` | Memoria de usuario cargada como instrucciones persistentes | +| `AGENTS.md` | Memoria de usuario cargada como instrucciones persistentes | | history files | Historial de entrada para flujos de trabajo interactivos | Evite hacer commit o compartir archivos de este directorio porque pueden contener secretos o preferencias locales. @@ -39,8 +39,10 @@ IaC Code tiene dos ubicaciones públicas de memoria: | Ubicación | Propósito | |---|---| -| `/IAC-CODE.md` | Memoria del proyecto. Puede hacerse commit cuando las instrucciones son útiles para todas las personas que trabajan en el proyecto. | -| `/IAC-CODE.md` | Memoria de usuario. Sigue `IAC_CODE_CONFIG_DIR` y es privada para el usuario local. | +| `/AGENTS.md` | Memoria del proyecto. Puede hacerse commit cuando las instrucciones son útiles para todas las personas que trabajan en el proyecto. | +| `/AGENTS.md` | Memoria de usuario. Sigue `IAC_CODE_CONFIG_DIR` y es privada para el usuario local. | + +Defina `IAC_CODE_INSTRUCTION_MEMORY_FILE` para usar otro nombre de archivo de memoria de instrucciones, por ejemplo `IAC-CODE.md`. Los archivos de temas de auto-memory del proyecto se almacenan en: @@ -48,7 +50,7 @@ Los archivos de temas de auto-memory del proyecto se almacenan en: /projects//memory/ ``` -`MEMORY.md` en esa carpeta es el índice de temas. Cuando auto-memory está activada, IaC Code puede usar una side call para seleccionar archivos de temas relevantes y añadirlos como contexto oculto de la conversación. +`MEMORY.md` en esa carpeta es el índice de temas usado por las side calls de auto-memory. No se carga como contexto permanente. Cuando auto-memory está activada, IaC Code puede seleccionar archivos de temas relevantes y añadirlos como contexto oculto de la conversación. ## Configuración del proyecto diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md index fb4b185..b265636 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ Le texte après le nom de la commande est transmis comme arguments. Dans le tabl | `/effort [level]` | Afficher ou modifier l'effort de réflexion pour le modèle actif lorsque le modèle sélectionné prend en charge le contrôle d'effort. Avec un niveau, il applique la valeur demandée si elle est valide pour le modèle. Sans niveau, il ouvre un sélecteur interactif dans le REPL, ou affiche l'effort actuel dans les contextes non interactifs. | | `/exit` | Quitter le REPL interactif. Alias : `/quit`, `/q`. | | `/help` | Afficher les commandes disponibles et les raccourcis clavier courants dans le REPL. Alias : `/?`. | -| `/memory` | Ouvrir le sélecteur de mémoire. Modifiez les fichiers `IAC-CODE.md` de projet ou d'utilisateur, activez ou désactivez auto-memory, et ouvrez le dossier auto-memory du projet lorsque auto-memory est activé. | +| `/memory` | Ouvrir le sélecteur de mémoire. Modifiez les fichiers `AGENTS.md` de projet ou d'utilisateur, activez ou désactivez auto-memory, et ouvrez le dossier auto-memory du projet lorsque auto-memory est activé. | | `/model [model_name]` | Afficher ou changer le modèle actif. Avec `model_name`, il bascule directement vers ce modèle pour le fournisseur actif. Sans argument, il ouvre un sélecteur de modèle interactif lorsqu'un fournisseur est configuré, ou affiche le modèle actuel lorsqu'aucune interface console n'est disponible. | | `/rename ` | Nommer la session actuelle. Les noms apparaissent dans la bannière d'accueil, l'indication de sortie et le sélecteur `/resume`, et peuvent être utilisés avec `/resume` ou `--resume` lorsqu'ils identifient une session de façon unique. | | `/resume [id-de-session\|préfixe-id-unique\|nom-de-session-unique]` | Reprendre une session précédente. Avec un argument, IaC Code le résout comme identifiant exact, préfixe d'identifiant unique ou nom de session unique. Sans argument, il ouvre le sélecteur de session interactif. Les sessions inter-projets affichent une commande `cd ... && iac-code --resume ` au lieu de basculer le projet courant à chaud. | @@ -33,8 +33,9 @@ La liste exacte des commandes peut varier entre les versions. Utilisez `/help` o Utilisez `/memory` pour modifier les fichiers mémoire que IaC Code charge dans la conversation : -- La mémoire de projet est enregistrée dans `IAC-CODE.md` à la racine du projet. -- La mémoire utilisateur est enregistrée dans `IAC-CODE.md` dans le répertoire de configuration d'exécution, `~/.iac-code/` par défaut. +- La mémoire de projet est enregistrée dans `AGENTS.md` à la racine du projet par défaut. +- La mémoire utilisateur est enregistrée dans `AGENTS.md` dans le répertoire de configuration d'exécution, `~/.iac-code/` par défaut. +- Définissez `IAC_CODE_INSTRUCTION_MEMORY_FILE` pour utiliser un autre nom de fichier, par exemple `IAC-CODE.md`. - L'éditeur est un éditeur plein écran compact de style Vim. Utilisez `i`, `a` ou `o` pour entrer en mode insertion, `Esc` pour revenir au mode normal, `:wq` pour enregistrer et `:q!` pour abandonner. - La ligne `Auto-memory` se bascule avec `Enter`. Lorsque auto-memory est activé, IaC Code peut rappeler des mémoires de sujet du projet comme contexte de conversation masqué. - L'option de dossier auto-memory n'apparaît que lorsque auto-memory est activé. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 4bd6f1f..d3d3c75 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ Fichiers courants : | `.credentials.yml` | Identifiants LLM | | `.cloud-credentials.yml` | Identifiants du fournisseur cloud | | `settings.yml` | Fournisseur sélectionné, modèle et paramètres associés | -| `IAC-CODE.md` | Mémoire utilisateur chargée comme instructions persistantes | +| `AGENTS.md` | Mémoire utilisateur chargée comme instructions persistantes | | history files | Historique de saisie pour les flux de travail interactifs | Évitez de commiter ou de partager les fichiers de ce répertoire car ils peuvent contenir des secrets ou des préférences locales. @@ -39,8 +39,10 @@ IaC Code possède deux emplacements mémoire publics : | Emplacement | Objectif | |---|---| -| `/IAC-CODE.md` | Mémoire de projet. Elle peut être commitée lorsque les instructions sont utiles à toutes les personnes travaillant sur le projet. | -| `/IAC-CODE.md` | Mémoire utilisateur. Elle suit `IAC_CODE_CONFIG_DIR` et reste privée pour l'utilisateur local. | +| `/AGENTS.md` | Mémoire de projet. Elle peut être commitée lorsque les instructions sont utiles à toutes les personnes travaillant sur le projet. | +| `/AGENTS.md` | Mémoire utilisateur. Elle suit `IAC_CODE_CONFIG_DIR` et reste privée pour l'utilisateur local. | + +Définissez `IAC_CODE_INSTRUCTION_MEMORY_FILE` pour utiliser un autre nom de fichier de mémoire d'instructions, par exemple `IAC-CODE.md`. Les fichiers de sujets auto-memory du projet sont stockés sous : @@ -48,7 +50,7 @@ Les fichiers de sujets auto-memory du projet sont stockés sous : /projects//memory/ ``` -`MEMORY.md` dans ce dossier est l'index des sujets. Lorsque auto-memory est activé, IaC Code peut utiliser un side call pour sélectionner les fichiers de sujets pertinents et les ajouter comme contexte de conversation masqué. +`MEMORY.md` dans ce dossier est l'index des sujets utilisé par les side calls auto-memory. Il n'est pas chargé comme contexte permanent. Lorsque auto-memory est activé, IaC Code peut sélectionner des fichiers de sujets pertinents et les ajouter comme contexte de conversation masqué. ## Paramètres du projet diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md index 868c881..2db8c12 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ description: 組み込み対話コマンドの完全リファレンス。 | `/effort [level]` | 選択したモデルがエフォート制御をサポートしている場合、アクティブモデルの思考エフォートを表示または変更します。レベルを指定すると、モデルに対して有効な値であれば適用します。レベルなしでは REPL で対話ピッカーを開くか、非対話コンテキストでは現在のエフォートを表示します。 | | `/exit` | 対話 REPL を終了します。エイリアス:`/quit`、`/q`。 | | `/help` | REPL 内で利用可能なコマンドと一般的なキーボードショートカットを表示します。エイリアス:`/?`。 | -| `/memory` | メモリセレクターを開きます。プロジェクトまたはユーザーの `IAC-CODE.md` ファイルを編集し、auto-memory を切り替え、auto-memory がオンのときはプロジェクトの auto-memory フォルダーを開けます。 | +| `/memory` | メモリセレクターを開きます。プロジェクトまたはユーザーの `AGENTS.md` ファイルを編集し、auto-memory を切り替え、auto-memory がオンのときはプロジェクトの auto-memory フォルダーを開けます。 | | `/model [model_name]` | アクティブなモデルを表示または切り替えます。`model_name` を指定すると、アクティブプロバイダーのそのモデルに直接切り替えます。引数なしでは、プロバイダーが設定されている場合は対話モデルピッカーを開き、コンソール UI が利用できない場合は現在のモデルを表示します。 | | `/rename <名前>` | 現在のセッションに名前を付けます。名前はウェルカムバナー、終了時のヒント、`/resume` ピッカーに表示され、一意にセッションを識別できる場合は `/resume` または `--resume` で使用できます。 | | `/resume [セッションID\|一意なIDプレフィックス\|一意なセッション名]` | 以前のセッションを再開します。引数を指定すると、IaC Code は正確なセッション ID、一意な ID プレフィックス、または一意なセッション名として解決します。引数なしでは対話セッションピッカーを開きます。プロジェクト間のセッションでは、現在のプロジェクトをその場で切り替えず、`cd ... && iac-code --resume ` コマンドを表示します。 | @@ -33,8 +33,9 @@ description: 組み込み対話コマンドの完全リファレンス。 `/memory` を使うと、IaC Code が会話に読み込むメモリファイルを編集できます。 -- プロジェクトメモリはプロジェクトルートの `IAC-CODE.md` に保存されます。 -- ユーザーメモリはランタイム設定ディレクトリ内の `IAC-CODE.md` に保存されます。既定では `~/.iac-code/` です。 +- プロジェクトメモリは既定でプロジェクトルートの `AGENTS.md` に保存されます。 +- ユーザーメモリはランタイム設定ディレクトリ内の `AGENTS.md` に保存されます。既定では `~/.iac-code/` です。 +- 別のファイル名を使うには `IAC_CODE_INSTRUCTION_MEMORY_FILE` を設定します。例: `IAC-CODE.md`。 - エディターはコンパクトな Vim 風の全画面エディターです。`i`、`a`、`o` で挿入モードに入り、`Esc` で通常モードに戻り、`:wq` で保存、`:q!` で破棄します。 - `Auto-memory` 行は `Enter` で切り替えられます。auto-memory がオンの場合、IaC Code は関連するプロジェクトのトピックメモリを隠し会話コンテキストとして呼び出せます。 - auto-memory フォルダーのオプションは、auto-memory がオンのときだけ表示されます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index f06f85e..0a730f1 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ CLI 引数 > 環境変数 > 設定ファイル | `.credentials.yml` | LLM 認証情報 | | `.cloud-credentials.yml` | クラウドプロバイダー認証情報 | | `settings.yml` | 選択されたプロバイダー、モデル、および関連設定 | -| `IAC-CODE.md` | 永続的な指示として読み込まれるユーザーメモリ | +| `AGENTS.md` | 永続的な指示として読み込まれるユーザーメモリ | | history files | 対話ワークフローの入力履歴 | このディレクトリのファイルにはシークレットやローカル設定が含まれる場合があるため、コミットや共有は避けてください。 @@ -39,8 +39,10 @@ IaC Code には公開されているメモリ場所が 2 つあります。 | 場所 | 用途 | |---|---| -| `/IAC-CODE.md` | プロジェクトメモリ。プロジェクトで作業する全員に役立つ指示であれば、コミットできます。 | -| `/IAC-CODE.md` | ユーザーメモリ。`IAC_CODE_CONFIG_DIR` に従い、ローカルユーザー専用です。 | +| `/AGENTS.md` | プロジェクトメモリ。プロジェクトで作業する全員に役立つ指示であれば、コミットできます。 | +| `/AGENTS.md` | ユーザーメモリ。`IAC_CODE_CONFIG_DIR` に従い、ローカルユーザー専用です。 | + +別の instruction memory ファイル名を使うには `IAC_CODE_INSTRUCTION_MEMORY_FILE` を設定します。例: `IAC-CODE.md`。 プロジェクトの auto-memory トピックファイルは以下に保存されます。 @@ -48,7 +50,7 @@ IaC Code には公開されているメモリ場所が 2 つあります。 /projects//memory/ ``` -そのフォルダー内の `MEMORY.md` はトピックインデックスです。auto-memory がオンの場合、IaC Code は side call を使って関連するトピックファイルを選択し、隠し会話コンテキストとして追加できます。 +そのフォルダー内の `MEMORY.md` は auto-memory side call が使うトピックインデックスです。常時コンテキストとしては読み込まれません。auto-memory がオンの場合、IaC Code は関連するトピックファイルを選択し、隠し会話コンテキストとして追加できます。 ## プロジェクト設定 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md index 036850a..6b48aa0 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ O texto apos o nome do comando e passado como argumentos. Na tabela abaixo, `` | Nomear a sessão atual. Os nomes aparecem no banner de boas-vindas, na dica de saída e no seletor de `/resume`, e podem ser usados com `/resume` ou `--resume` quando identificam uma sessão de forma única. | | `/resume [id-da-sessao\|prefixo-unico-de-id\|nome-unico-da-sessao]` | Retomar uma sessão anterior. Com um argumento, o IaC Code resolve-o como ID exato, prefixo único de ID ou nome único de sessão. Sem argumento, abre o seletor interativo de sessões. Sessões de outros projetos imprimem um comando `cd ... && iac-code --resume ` em vez de trocar o projeto atual em tempo real. | @@ -33,8 +33,9 @@ A lista exata de comandos pode mudar entre versoes. Use `/help` ou digite `/` no Use `/memory` para editar os arquivos de memória que o IaC Code carrega na conversa: -- A memória do projeto é salva em `IAC-CODE.md` na raiz do projeto. -- A memória do usuário é salva em `IAC-CODE.md` no diretório de configuração em tempo de execução, `~/.iac-code/` por padrão. +- A memória do projeto é salva em `AGENTS.md` na raiz do projeto por padrão. +- A memória do usuário é salva em `AGENTS.md` no diretório de configuração em tempo de execução, `~/.iac-code/` por padrão. +- Defina `IAC_CODE_INSTRUCTION_MEMORY_FILE` para usar outro nome de arquivo, por exemplo `IAC-CODE.md`. - O editor é um editor compacto em tela cheia no estilo Vim. Use `i`, `a` ou `o` para entrar no modo de inserção, `Esc` para voltar ao modo normal, `:wq` para salvar e `:q!` para descartar. - A linha `Auto-memory` pode ser alternada com `Enter`. Quando auto-memory está ativada, o IaC Code pode recuperar memórias de tópicos do projeto como contexto oculto da conversa. - A opção da pasta de auto-memory só aparece quando auto-memory está ativada. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 503dcf4..5eb98f4 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ Arquivos comuns: | `.credentials.yml` | Credenciais LLM | | `.cloud-credentials.yml` | Credenciais do provedor de nuvem | | `settings.yml` | Provedor selecionado, modelo e configurações relacionadas | -| `IAC-CODE.md` | Memória do usuário carregada como instruções persistentes | +| `AGENTS.md` | Memória do usuário carregada como instruções persistentes | | history files | Histórico de entrada para fluxos de trabalho interativos | Evite fazer commit ou compartilhar arquivos deste diretório porque eles podem conter segredos ou preferências locais. @@ -39,8 +39,10 @@ O IaC Code tem dois locais públicos de memória: | Local | Finalidade | |---|---| -| `/IAC-CODE.md` | Memória do projeto. Pode ser commitada quando as instruções forem úteis para todas as pessoas que trabalham no projeto. | -| `/IAC-CODE.md` | Memória do usuário. Segue `IAC_CODE_CONFIG_DIR` e é privada para o usuário local. | +| `/AGENTS.md` | Memória do projeto. Pode ser commitada quando as instruções forem úteis para todas as pessoas que trabalham no projeto. | +| `/AGENTS.md` | Memória do usuário. Segue `IAC_CODE_CONFIG_DIR` e é privada para o usuário local. | + +Defina `IAC_CODE_INSTRUCTION_MEMORY_FILE` para usar outro nome de arquivo de memória de instruções, por exemplo `IAC-CODE.md`. Os arquivos de tópicos de auto-memory do projeto são armazenados em: @@ -48,7 +50,7 @@ Os arquivos de tópicos de auto-memory do projeto são armazenados em: /projects//memory/ ``` -`MEMORY.md` nessa pasta é o índice de tópicos. Quando auto-memory está ativada, o IaC Code pode usar uma side call para selecionar arquivos de tópicos relevantes e adicioná-los como contexto oculto da conversa. +`MEMORY.md` nessa pasta é o índice de tópicos usado pelas side calls de auto-memory. Ele não é carregado como contexto permanente. Quando auto-memory está ativada, o IaC Code pode selecionar arquivos de tópicos relevantes e adicioná-los como contexto oculto da conversa. ## Configurações do projeto diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md index d84befd..e0028b6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/commands.md @@ -20,7 +20,7 @@ Slash 命令用于在交互式会话中控制 IaC Code。输入 `/` 可以查看 | `/effort [level]` | 在当前模型支持 effort 控制时,查看或切换 thinking effort。带 `level` 时,如果该值对当前模型有效就会直接应用;不带参数时,在 REPL 中打开交互式选择器,在无控制台 UI 的场景中显示当前 effort。 | | `/exit` | 退出交互式 REPL。别名:`/quit`、`/q`。 | | `/help` | 在 REPL 中显示可用命令和常用快捷键。别名:`/?`。 | -| `/memory` | 打开记忆选择器。可以编辑项目或用户的 `IAC-CODE.md` 文件,切换 auto-memory,并在 auto-memory 开启时打开项目 auto-memory 文件夹。 | +| `/memory` | 打开记忆选择器。可以编辑项目或用户的 `AGENTS.md` 文件,切换 auto-memory,并在 auto-memory 开启时打开项目 auto-memory 文件夹。 | | `/model [model_name]` | 查看或切换当前模型。带 `model_name` 时,会直接为当前提供商切换到该模型;不带参数时,如果已配置提供商,会打开交互式模型选择器;在无控制台 UI 的场景中会显示当前模型。 | | `/rename ` | 为当前会话命名。名称会显示在欢迎横幅、退出提示和 `/resume` 选择器中;当它能唯一标识一个会话时,也可以用于 `/resume` 或 `--resume`。 | | `/resume [session id\|unique id prefix\|unique session name]` | 恢复历史会话。带参数时,IaC Code 会把它解析为精确会话 ID、唯一 ID 前缀或唯一会话名称;不带参数时打开交互式会话选择器。跨项目会话不会直接热切换,而是打印 `cd ... && iac-code --resume ` 命令。 | @@ -33,8 +33,9 @@ Slash 命令用于在交互式会话中控制 IaC Code。输入 `/` 可以查看 使用 `/memory` 可以编辑 IaC Code 加载到对话中的记忆文件: -- 项目记忆保存在项目根目录的 `IAC-CODE.md`。 -- 用户记忆保存在运行时配置目录中的 `IAC-CODE.md`,默认位于 `~/.iac-code/`。 +- 项目记忆默认保存在项目根目录的 `AGENTS.md`。 +- 用户记忆默认保存在运行时配置目录中的 `AGENTS.md`,默认位于 `~/.iac-code/`。 +- 可以设置 `IAC_CODE_INSTRUCTION_MEMORY_FILE` 使用其他文件名,例如 `IAC-CODE.md`。 - 编辑器是一个轻量的 Vim-like 全屏编辑器。使用 `i`、`a` 或 `o` 进入插入模式,`Esc` 回到普通模式,`:wq` 保存,`:q!` 放弃修改。 - `Auto-memory` 行可以按 `Enter` 切换。开启后,IaC Code 可以把相关的项目 topic memory 作为隐藏会话上下文召回。 - auto-memory 文件夹选项只会在 auto-memory 开启时显示。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 20c3e56..a9f2f7c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -28,7 +28,7 @@ CLI 参数 > 环境变量 > 配置文件 | `.credentials.yml` | LLM 凭证 | | `.cloud-credentials.yml` | 云厂商凭证 | | `settings.yml` | 已选择的提供商、模型和相关设置 | -| `IAC-CODE.md` | 作为持久指令加载的用户记忆 | +| `AGENTS.md` | 作为持久指令加载的用户记忆 | | history files | 交互式工作流的输入历史 | 避免提交或分享该目录中的文件,因为它们可能包含密钥或本地偏好。 @@ -39,8 +39,10 @@ IaC Code 有两个公开的记忆位置: | 位置 | 用途 | |---|---| -| `/IAC-CODE.md` | 项目记忆。当这些指令对项目协作者都有用时,可以提交到版本库。 | -| `/IAC-CODE.md` | 用户记忆。它跟随 `IAC_CODE_CONFIG_DIR`,只属于本地用户。 | +| `/AGENTS.md` | 项目记忆。当这些指令对项目协作者都有用时,可以提交到版本库。 | +| `/AGENTS.md` | 用户记忆。它跟随 `IAC_CODE_CONFIG_DIR`,只属于本地用户。 | + +可以设置 `IAC_CODE_INSTRUCTION_MEMORY_FILE` 使用其他指令记忆文件名,例如 `IAC-CODE.md`。 项目 auto-memory topic 文件存放在: @@ -48,7 +50,7 @@ IaC Code 有两个公开的记忆位置: /projects//memory/ ``` -该文件夹中的 `MEMORY.md` 是 topic 索引。auto-memory 开启时,IaC Code 可能通过 side call 选择相关 topic 文件,并把它们作为隐藏会话上下文加入对话。 +该文件夹中的 `MEMORY.md` 是供 auto-memory side call 使用的 topic 索引,不会作为常驻上下文加载。auto-memory 开启时,IaC Code 可能选择相关 topic 文件,并把它们作为隐藏会话上下文加入对话。 ## 项目级设置 From 9f284331dd65d7c094bf9d84edae2b86abba4317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 8 Jun 2026 15:38:07 +0800 Subject: [PATCH 3/4] fix: report in-flight memory recall queries --- src/iac_code/agent/agent_loop.py | 1 + src/iac_code/commands/status.py | 19 ++++-- .../i18n/locales/de/LC_MESSAGES/messages.po | 13 ++-- .../i18n/locales/es/LC_MESSAGES/messages.po | 13 ++-- .../i18n/locales/fr/LC_MESSAGES/messages.po | 13 ++-- .../i18n/locales/ja/LC_MESSAGES/messages.po | 13 ++-- .../i18n/locales/pt/LC_MESSAGES/messages.po | 13 ++-- .../i18n/locales/zh/LC_MESSAGES/messages.po | 13 ++-- src/iac_code/memory/recall.py | 6 ++ tests/commands/test_status.py | 61 +++++++++++++++++++ tests/memory/test_recall.py | 37 +++++++++++ 11 files changed, 172 insertions(+), 30 deletions(-) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 89ae6bf..a8204f1 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -189,6 +189,7 @@ def get_memory_recall_stats(self) -> dict[str, Any]: if self._memory_recall_service is None: return { "total_side_queries": 0, + "in_flight_side_queries": 0, "successful_side_queries": 0, "failed_side_queries": 0, "cancelled_side_queries": 0, diff --git a/src/iac_code/commands/status.py b/src/iac_code/commands/status.py index edc5702..d41f32c 100644 --- a/src/iac_code/commands/status.py +++ b/src/iac_code/commands/status.py @@ -71,15 +71,22 @@ def _should_show_memory_recall() -> bool: def _append_memory_recall(text: Text, memory_recall: dict[str, Any]) -> None: text.append(_("Memory Recall"), style="bold") text.append("\n") + in_flight = int(memory_recall.get("in_flight_side_queries") or 0) + side_query_summary = _("{total} total, {success} success, {failed} failed, {cancelled} cancelled").format( + total=int(memory_recall.get("total_side_queries") or 0), + success=int(memory_recall.get("successful_side_queries") or 0), + failed=int(memory_recall.get("failed_side_queries") or 0), + cancelled=int(memory_recall.get("cancelled_side_queries") or 0), + ) + if in_flight > 0: + side_query_summary = _("{summary}, {in_flight} in progress").format( + summary=side_query_summary, + in_flight=in_flight, + ) _append_line( text, _("Side queries"), - _("{total} total, {success} success, {failed} failed, {cancelled} cancelled").format( - total=int(memory_recall.get("total_side_queries") or 0), - success=int(memory_recall.get("successful_side_queries") or 0), - failed=int(memory_recall.get("failed_side_queries") or 0), - cancelled=int(memory_recall.get("cancelled_side_queries") or 0), - ), + side_query_summary, indent=2, ) last_attempt_files = [str(item) for item in memory_recall.get("last_selected_files") or []] diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index b1befc3..2a8e3ee 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -1695,10 +1695,6 @@ msgstr "Sitzungsstatus" msgid "Memory Recall" msgstr "Speicherabruf" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "Nebenabfragen" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" @@ -1706,6 +1702,15 @@ msgstr "" "{total} insgesamt, {success} erfolgreich, {failed} fehlgeschlagen, " "{cancelled} abgebrochen" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary}, {in_flight} in Bearbeitung" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Nebenabfragen" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "Letzter Versuch" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 14954d0..56ad22f 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -1699,10 +1699,6 @@ msgstr "Estado de la sesión" msgid "Memory Recall" msgstr "Recuperacion de memoria" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "Consultas laterales" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" @@ -1710,6 +1706,15 @@ msgstr "" "{total} en total, {success} correctas, {failed} fallidas, {cancelled} " "canceladas" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary}, {in_flight} en curso" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Consultas laterales" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "Ultimo intento" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 0f17365..637014c 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -1702,10 +1702,6 @@ msgstr "État de la session" msgid "Memory Recall" msgstr "Rappel memoire" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "Requetes laterales" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" @@ -1713,6 +1709,15 @@ msgstr "" "{total} au total, {success} reussies, {failed} echouees, {cancelled} " "annulees" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary}, {in_flight} en cours" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Requetes laterales" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "Derniere tentative" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 3213bbe..7cd0e03 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -1655,15 +1655,20 @@ msgstr "セッション状態" msgid "Memory Recall" msgstr "メモリ呼び出し" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "サイドクエリ" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" msgstr "合計 {total}、成功 {success}、失敗 {failed}、キャンセル {cancelled}" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary}、{in_flight} 件進行中" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "サイドクエリ" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "直近の試行" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index f2a6089..3dc5777 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -1691,10 +1691,6 @@ msgstr "Status da sessão" msgid "Memory Recall" msgstr "Recuperacao de memoria" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "Consultas laterais" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" @@ -1702,6 +1698,15 @@ msgstr "" "{total} no total, {success} com sucesso, {failed} com falha, {cancelled} " "canceladas" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary}, {in_flight} em andamento" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "Consultas laterais" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "Ultima tentativa" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 5c10b9e..78b2166 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -1643,15 +1643,20 @@ msgstr "会话状态" msgid "Memory Recall" msgstr "记忆召回" -#: src/iac_code/commands/status.py -msgid "Side queries" -msgstr "旁路查询" - #: src/iac_code/commands/status.py #, python-brace-format msgid "{total} total, {success} success, {failed} failed, {cancelled} cancelled" msgstr "{total} 次总计,{success} 次成功,{failed} 次失败,{cancelled} 次取消" +#: src/iac_code/commands/status.py +#, python-brace-format +msgid "{summary}, {in_flight} in progress" +msgstr "{summary},{in_flight} 个进行中" + +#: src/iac_code/commands/status.py +msgid "Side queries" +msgstr "旁路查询" + #: src/iac_code/commands/status.py msgid "Last attempt" msgstr "最近尝试" diff --git a/src/iac_code/memory/recall.py b/src/iac_code/memory/recall.py index 5b79cfb..704daf5 100644 --- a/src/iac_code/memory/recall.py +++ b/src/iac_code/memory/recall.py @@ -56,6 +56,7 @@ def cancel(self) -> None: @dataclass class MemoryRecallStats: total_side_queries: int = 0 + in_flight_side_queries: int = 0 successful_side_queries: int = 0 failed_side_queries: int = 0 cancelled_side_queries: int = 0 @@ -76,6 +77,7 @@ class MemoryRecallStats: def snapshot(self) -> dict[str, Any]: return { "total_side_queries": self.total_side_queries, + "in_flight_side_queries": self.in_flight_side_queries, "successful_side_queries": self.successful_side_queries, "failed_side_queries": self.failed_side_queries, "cancelled_side_queries": self.cancelled_side_queries, @@ -196,10 +198,12 @@ async def _recall(self, user_input: str, *, timeout_seconds: float | None) -> Me return MemoryRecallResult(status="skipped") self._stats.total_side_queries += 1 + self._stats.in_flight_side_queries += 1 response_usage: Usage | None = None prompt = self._build_user_prompt(user_input, manifest) response_text = "" self._stats.last_usage = MemoryRecallUsageStats() + self._record("pending", started, selected_files=[], prompt=prompt, side_query=True) try: completion = self._provider_manager.complete( messages=[Message.user(prompt)], @@ -227,6 +231,8 @@ async def _recall(self, user_input: str, *, timeout_seconds: float | None) -> Me self._stats.failed_side_queries += 1 self._record("failed", started, selected_files=[], prompt=prompt, response=response_text, side_query=True) return MemoryRecallResult(status="failed", usage=response_usage) + finally: + self._stats.in_flight_side_queries = max(0, self._stats.in_flight_side_queries - 1) content = self._read_selected_files(selected_files) self._stats.successful_side_queries += 1 diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index d6d2329..f5d2e22 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -288,6 +288,67 @@ async def test_status_prints_memory_recall_metrics_in_debug(monkeypatch) -> None assert '{"files":["project-deadline.md"]}' not in rendered +@pytest.mark.asyncio +async def test_status_prints_inflight_memory_recall_metrics_in_debug(monkeypatch) -> None: + monkeypatch.setattr("iac_code.utils.log.is_debug_enabled", lambda: True) + console = MagicMock() + repl = MagicMock() + repl.get_status_snapshot.return_value = { + "session_id": "memory", + "resumed": False, + "provider": "dashscope", + "model": "qwen", + "region": "cn-beijing", + "cwd": "/tmp/status-project", + "api_usage": _usage(), + "turn_count": 2, + "max_turns": 100, + "context_usage": { + "total_tokens": 1000, + "context_window": 128000, + "usage_percent": 1.0, + }, + "memory_recall": { + "total_side_queries": 2, + "in_flight_side_queries": 2, + "successful_side_queries": 0, + "failed_side_queries": 0, + "cancelled_side_queries": 0, + "last_duration_ms": 0, + "last_status": "pending", + "last_selected_files": [], + "last_side_query_duration_ms": 0, + "last_side_query_status": "pending", + "last_side_query_selected_files": [], + "total_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + "last_usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 0, + "recorded_events": 0, + "has_recorded_usage": False, + }, + }, + } + context = MagicMock(console=console, repl=repl) + + await status_command(context=context) + + rendered = _render_text(console.print.call_args.args[0]) + assert "2 total, 0 success, 0 failed, 0 cancelled, 2 in progress" in rendered + assert "pending in 0 ms, 0 files selected" in rendered + + @pytest.mark.asyncio async def test_status_uses_compiled_translations(monkeypatch) -> None: monkeypatch.setenv("LANGUAGE", "zh") diff --git a/tests/memory/test_recall.py b/tests/memory/test_recall.py index 33bb708..b252c77 100644 --- a/tests/memory/test_recall.py +++ b/tests/memory/test_recall.py @@ -269,6 +269,42 @@ async def test_start_prefetch_returns_without_waiting_for_provider(memory_manage assert service.get_stats_snapshot()["last_status"] == "success" +@pytest.mark.asyncio +async def test_inflight_prefetch_is_reported_as_pending(memory_manager): + from iac_code.memory.recall import MemoryRecallService + + service = MemoryRecallService( + memory_manager=memory_manager, + provider_manager=FakeRecallProvider(json.dumps({"files": ["project-deadline.md"]}), delay=0.05), + ) + + prefetch = service.start_prefetch("deadline") + assert prefetch is not None + + for _ in range(20): + stats = service.get_stats_snapshot() + if stats["in_flight_side_queries"] == 1: + break + await asyncio.sleep(0) + + stats = service.get_stats_snapshot() + assert stats["total_side_queries"] == 1 + assert stats["in_flight_side_queries"] == 1 + assert stats["successful_side_queries"] == 0 + assert stats["failed_side_queries"] == 0 + assert stats["cancelled_side_queries"] == 0 + assert stats["last_status"] == "pending" + assert stats["last_side_query_status"] == "pending" + + result = await prefetch.wait() + + stats = service.get_stats_snapshot() + assert result.selected_files == ["project-deadline.md"] + assert stats["in_flight_side_queries"] == 0 + assert stats["successful_side_queries"] == 1 + assert stats["last_side_query_status"] == "success" + + @pytest.mark.asyncio async def test_prefetch_uses_turn_lifetime_instead_of_sync_timeout(memory_manager): from iac_code.memory.recall import MemoryRecallService @@ -501,6 +537,7 @@ def test_recall_stats_can_be_reset(memory_manager): assert service.get_stats_snapshot() == { "total_side_queries": 0, + "in_flight_side_queries": 0, "successful_side_queries": 0, "failed_side_queries": 0, "cancelled_side_queries": 0, From 46b9197e6788d7dc42e1036b405ee2a2babdfeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 8 Jun 2026 16:07:43 +0800 Subject: [PATCH 4/4] fix: handle memory recall timeout on python 3.10 --- src/iac_code/memory/recall.py | 2 +- tests/memory/test_project_memory.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/iac_code/memory/recall.py b/src/iac_code/memory/recall.py index 704daf5..d8f2cdf 100644 --- a/src/iac_code/memory/recall.py +++ b/src/iac_code/memory/recall.py @@ -223,7 +223,7 @@ async def _recall(self, user_input: str, *, timeout_seconds: float | None) -> Me response_text = str(getattr(response, "text", "")) selected_files = self._parse_selected_files(response_text, manifest) selected_files = self._filter_unsuppressed_files(selected_files) - except TimeoutError: + except (asyncio.TimeoutError, TimeoutError): self._stats.failed_side_queries += 1 self._record("timeout", started, selected_files=[], prompt=prompt, response=response_text, side_query=True) return MemoryRecallResult(status="timeout") diff --git a/tests/memory/test_project_memory.py b/tests/memory/test_project_memory.py index db5a7d9..2f9c9a9 100644 --- a/tests/memory/test_project_memory.py +++ b/tests/memory/test_project_memory.py @@ -101,6 +101,7 @@ def test_ensure_project_instruction_file_returns_path_without_creating_empty_fil project = tmp_path / "project" project.mkdir() project.chmod(0o755) + original_mode = stat.S_IMODE(project.stat().st_mode) monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) runtime = mod.ProjectMemoryRuntime(str(project)) @@ -108,7 +109,7 @@ def test_ensure_project_instruction_file_returns_path_without_creating_empty_fil assert created == project / "AGENTS.md" assert not created.exists() - assert stat.S_IMODE(project.stat().st_mode) == 0o755 + assert stat.S_IMODE(project.stat().st_mode) == original_mode def test_auto_memory_enabled_defaults_to_true_and_persists(tmp_path, monkeypatch):