diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index bcf93cd..792e1a1 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -2,7 +2,7 @@ This document tracks what is already implemented in Python and what is still missing compared with the upstream npm runtime. -This is a functionality-oriented checklist, not a line-by-line source equivalence claim. Large parts of the mirrored Python workspace still act as inventory or scaffolding, while the working Python runtime currently lives mainly in [`src/agent_runtime.py`](src/agent_runtime.py), [`src/query_engine.py`](src/query_engine.py), [`src/agent_tools.py`](src/agent_tools.py), [`src/agent_prompting.py`](src/agent_prompting.py), [`src/agent_context.py`](src/agent_context.py), [`src/agent_manager.py`](src/agent_manager.py), [`src/plugin_runtime.py`](src/plugin_runtime.py), [`src/agent_slash_commands.py`](src/agent_slash_commands.py), and [`src/openai_compat.py`](src/openai_compat.py). +This is a functionality-oriented checklist, not a line-by-line source equivalence claim. Large parts of the mirrored Python workspace still act as inventory or scaffolding, while the working Python runtime currently lives mainly in [`src/agent_runtime.py`](src/agent_runtime.py), [`src/query_engine.py`](src/query_engine.py), [`src/agent_tools.py`](src/agent_tools.py), [`src/agent_prompting.py`](src/agent_prompting.py), [`src/agent_context.py`](src/agent_context.py), [`src/agent_manager.py`](src/agent_manager.py), [`src/plugin_runtime.py`](src/plugin_runtime.py), [`src/agent_slash_commands.py`](src/agent_slash_commands.py), [`src/openai_compat.py`](src/openai_compat.py), [`src/builtin_agents.py`](src/builtin_agents.py), [`src/microcompact.py`](src/microcompact.py), [`src/compact.py`](src/compact.py), [`src/bundled_skills.py`](src/bundled_skills.py), and [`src/session_memory_compact.py`](src/session_memory_compact.py). --- @@ -254,7 +254,7 @@ Missing: - [ ] Full tokenizer/chat-message framing parity beyond the current model-aware text token counters - [ ] Full parity with `utils/queryContext.ts` (context analysis, suggestions, cache shaping) -- [ ] Rich memory prompt loading (`services/SessionMemory/`) +- [x] Session memory compact (`services/SessionMemory/` partial) → `src/session_memory_compact.py` — remaining: background LLM extraction, full template handling - [ ] Internal permission-aware memory handling - [ ] Resume-aware prompt cache shaping used upstream - [ ] More exact context cache invalidation rules @@ -269,7 +269,7 @@ Missing: ## 5. Slash Commands -Done (37 slash command names in 29 specs): +Done (53 slash command names in 37 specs): - [x] `/help`, `/commands` - [x] `/context`, `/usage` @@ -311,7 +311,7 @@ Done (37 slash command names in 29 specs): Missing npm slash commands (from `src/commands/` — 80+ commands total): -- [ ] `/add-dir` — Add a new working directory +- [x] `/add-dir` — Add a new working directory - [ ] `/agents` — Manage agent configurations - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions @@ -328,7 +328,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/exit` — Exit the REPL - [x] `/export` — Export conversation to file or clipboard - [ ] `/extra-usage` — Configure extra usage for rate limits -- [ ] `/fast` — Toggle fast mode +- [x] `/fast` — Toggle fast mode - [ ] `/feedback` — Submit feedback - [x] `/files` — List all files currently in context - [ ] `/ide` — Manage IDE integrations and show status @@ -339,7 +339,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] `/output-style` — Change output style - [ ] `/passes` — Passes management - [ ] `/plugin` — Plugin management -- [ ] `/pr_comments` — Get comments from a GitHub PR +- [x] `/pr-comments`, `/pr_comments` — Get comments from a GitHub PR (prompt-type) - [ ] `/privacy-settings` — View/update privacy settings - [ ] `/rate-limit-options` — Show options when rate limited - [ ] `/release-notes` — View release notes @@ -347,23 +347,24 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] `/remote-env` — Configure default remote environment - [ ] `/remote-setup` — Remote setup configuration - [x] `/rename` — Rename current conversation -- [ ] `/resume` — Resume a previous conversation -- [ ] `/rewind` — Restore code/conversation to a previous point +- [x] `/resume`, `/continue` — Resume a previous conversation +- [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point - [ ] `/sandbox-toggle` — Toggle sandbox mode -- [ ] `/skills` — List available skills +- [x] `/skills` — List available skills - [x] `/stats` — Usage statistics and activity - [ ] `/stickers` — Order stickers - [x] `/tag` — Toggle a searchable tag on the session - [ ] `/theme` — Change the theme - [ ] `/upgrade` — Upgrade to Max -- [ ] `/vim` — Toggle Vim/Normal editing modes +- [x] `/vim` — Toggle Vim/Normal editing modes - [ ] `/voice` — Toggle voice mode - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. -- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. +- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. +- [x] `/commit` — Create a git commit (prompt-type with injected git context) ## 6. Built-in Tools -### Tools implemented in Python (61 tools): +### Tools implemented in Python (65 tools): - [x] `list_dir` - [x] `read_file` @@ -424,12 +425,16 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `team_delete` - [x] `send_message` - [x] `team_messages` +- [x] `EnterPlanMode` +- [x] `ExitPlanMode` +- [x] `TaskOutput` +- [x] `TaskStop` ### Tools in npm `tools.ts` not yet ported with full fidelity (40 tool dirs): Core tools needing full port: - [x] `AgentTool` — Sub-agent spawning with built-in agents (explore, general-purpose, verification, plan, claudeCodeGuide, statusline) → `src/builtin_agents.py` — remaining: fork support, agent memory/snapshots, resume agent, color management -- [x] `SkillTool` — Skill execution via slash commands → `src/agent_tools.py`, `src/agent_runtime.py` — remaining: bundled skill definitions, forked skill execution +- [x] `SkillTool` — Skill execution via slash commands and bundled skills → `src/agent_tools.py`, `src/agent_runtime.py`, `src/bundled_skills.py` — remaining: forked skill execution - [ ] `BriefTool` — Brief mode with attachments and file upload - [ ] `LSPTool` — Full upstream LSP fidelity beyond the current local heuristic runtime (server-backed diagnostics, go-to-definition, references, hover, symbol search, formatting) - [ ] `PowerShellTool` — Full PowerShell execution with security, path validation, CLM types, git safety @@ -438,12 +443,12 @@ Core tools needing full port: - [ ] `McpAuthTool` — MCP authentication handling - [ ] `ConfigTool` — Full config management with supported settings list - [ ] `SyntheticOutputTool` — Synthetic output injection -- [ ] `EnterPlanModeTool` — Enter plan mode with UI -- [ ] `ExitPlanModeTool` — Exit plan mode with V2 flow +- [x] `EnterPlanModeTool` — Enter plan mode → `src/agent_tools.py` +- [x] `ExitPlanModeTool` — Exit plan mode → `src/agent_tools.py` - [ ] `EnterWorktreeTool` — Full worktree enter with UI - [ ] `ExitWorktreeTool` — Full worktree exit with UI -- [ ] `TaskOutputTool` — Task output display -- [ ] `TaskStopTool` — Stop a running task +- [x] `TaskOutputTool` — Task output display → `src/agent_tools.py` +- [x] `TaskStopTool` — Stop a running task → `src/agent_tools.py` Feature-gated tools: - [ ] `CronCreateTool` / `CronDeleteTool` / `CronListTool` — Cron scheduling (AGENT_TRIGGERS) @@ -546,8 +551,8 @@ Missing: - [ ] Bundled plugin support (`plugins/bundledPlugins.ts`, `plugins/bundled/`) - [ ] Plugin lifecycle management - [ ] Plugin update/cache behavior -- [ ] Skill discovery and execution (`skills/bundledSkills.ts`, `skills/loadSkillsDir.ts`, `skills/mcpSkillBuilders.ts`, `skills/bundled/`) -- [ ] Bundled skill support +- [x] Skill discovery and execution → `src/bundled_skills.py` (simplify, verify, debug, update-config) — remaining: loadSkillsDir, mcpSkillBuilders, disk-based SKILL.md loading +- [x] Bundled skill support → `src/bundled_skills.py` — remaining: skillify, batch, loop, schedule, claude-api, chrome, and feature-gated skills - [ ] Full plugin and skill parity ## 10. Interactive UI / REPL / TUI @@ -629,7 +634,7 @@ Missing: - [ ] API service (`services/api/` — 20+ files: claude client, dumpPrompts, errorUtils, filesApi, firstTokenDate, grove, logging, metricsOptOut, promptCacheBreakDetection, sessionIngress, usage, withRetry, etc.) - [ ] LSP service (`services/lsp/` — 7 files: LSPClient, LSPDiagnosticRegistry, LSPServerInstance, LSPServerManager, config, manager, passiveFeedback) - [ ] Tools service (`services/tools/` — 4 files: StreamingToolExecutor, toolExecution, toolHooks, toolOrchestration) -- [ ] Compact service (`services/compact/` — 6 files: compact, autoCompact, microCompact, apiMicrocompact, sessionMemoryCompact, compactWarningHook) +- [ ] Compact service (`services/compact/` — 6 files) — partially ported: compact → `src/compact.py`, microCompact → `src/microcompact.py`, sessionMemoryCompact → `src/session_memory_compact.py`; remaining: autoCompact trigger, apiMicrocompact, compactWarningHook - [ ] Auto-dream service (`services/autoDream/` — 4 files: autoDream, config, consolidationLock, consolidationPrompt) - [ ] Agent summary service (`services/AgentSummary/`) - [ ] Magic docs service (`services/MagicDocs/`) diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 8e550d3..d509259 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -2042,12 +2042,12 @@ class LocalCodingAgent: self, arguments: dict[str, object], ) -> ToolExecutionResult: - """Execute a skill (slash command) through the Skill tool. + """Execute a skill through the Skill tool. - Maps the ``skill`` parameter to a slash command, invokes it, and - returns its output as a tool result. + Checks bundled skills first, then falls back to slash commands. """ from .agent_slash_commands import find_slash_command, get_slash_command_specs + from .bundled_skills import find_bundled_skill, get_bundled_skills skill_name = arguments.get('skill') if not isinstance(skill_name, str) or not skill_name.strip(): @@ -2063,20 +2063,38 @@ class LocalCodingAgent: if not isinstance(args, str): args = str(args) if args is not None else '' - # Look up the slash command + # 1. Check bundled skills first + bundled = find_bundled_skill(skill_name) + if bundled is not None: + prompt = bundled.get_prompt(self, args.strip()) + return ToolExecutionResult( + name='Skill', + ok=True, + content=prompt, + metadata={ + 'action': 'skill', + 'skill_name': skill_name, + 'source': 'bundled', + 'should_query': True, + }, + ) + + # 2. Fall back to slash commands spec = find_slash_command(skill_name) if spec is None: - available = sorted( + available_cmds = sorted( name - for spec in get_slash_command_specs() - for name in spec.names + for s in get_slash_command_specs() + for name in s.names ) + available_skills = [sk.name for sk in get_bundled_skills()] + all_available = sorted(set(available_cmds + available_skills)) return ToolExecutionResult( name='Skill', ok=False, content=( f'Unknown skill: {skill_name}. ' - f'Available skills: {", ".join(available[:30])}' + f'Available skills: {", ".join(all_available[:30])}' ), metadata={'action': 'skill_not_found', 'skill_name': skill_name}, ) diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 3590962..0bd4070 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -369,6 +369,46 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Diagnose and verify the claw-code installation and settings.', handler=_handle_doctor, ), + SlashCommandSpec( + names=('commit',), + description='Create a git commit.', + handler=_handle_commit, + ), + SlashCommandSpec( + names=('pr-comments', 'pr_comments'), + description='Get comments from a GitHub pull request.', + handler=_handle_pr_comments, + ), + SlashCommandSpec( + names=('resume', 'continue'), + description='Resume a previous conversation.', + handler=_handle_resume, + ), + SlashCommandSpec( + names=('add-dir',), + description='Add a new working directory.', + handler=_handle_add_dir, + ), + SlashCommandSpec( + names=('skills',), + description='List available skills.', + handler=_handle_skills, + ), + SlashCommandSpec( + names=('fast',), + description='Toggle fast mode.', + handler=_handle_fast, + ), + SlashCommandSpec( + names=('vim',), + description='Toggle between Vim and Normal editing modes.', + handler=_handle_vim, + ), + SlashCommandSpec( + names=('rewind', 'checkpoint'), + description='Restore the conversation to a previous point.', + handler=_handle_rewind, + ), ) @@ -1185,6 +1225,286 @@ def _handle_doctor(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sl return _local_result(input_text, output) +def _handle_commit(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Create a git commit — prompt-type command injecting git context.""" + import subprocess + + cwd = str(agent.runtime_config.cwd) + + def _git(cmd_args: list[str]) -> str: + try: + proc = subprocess.run( + ['git'] + cmd_args, + cwd=cwd, capture_output=True, text=True, timeout=15, + ) + return proc.stdout.strip() + except Exception: + return '' + + status = _git(['status', '--short']) + staged = _git(['diff', '--staged']) + unstaged = _git(['diff']) + branch = _git(['branch', '--show-current']) + recent = _git(['log', '--oneline', '-10']) + + sections: list[str] = [] + if branch: + sections.append(f'Current branch: {branch}') + if status: + sections.append(f'### Status\n```\n{status}\n```') + if staged: + sections.append(f'### Staged diff\n```\n{staged}\n```') + if unstaged: + sections.append(f'### Unstaged diff\n```\n{unstaged}\n```') + if recent: + sections.append(f'### Recent commits\n```\n{recent}\n```') + + git_context = '\n\n'.join(sections) if sections else 'No git changes detected.' + + prompt = f"""Create a git commit for the current changes. + +{git_context} + +Instructions: +1. Review the changes above +2. Stage appropriate files with `git add ` (prefer specific files over `git add -A`) +3. Draft a concise commit message following the repo's conventions +4. Use a HEREDOC for multi-line messages: + git commit -m "$(cat <<'EOF' + message here + EOF + )" + +Safety: +- Always create NEW commits (never --amend) +- Never skip hooks (--no-verify) +- Never commit secrets (.env, credentials, etc.) +- No interactive flags (-i)""" + + if args.strip(): + prompt += f'\n\nAdditional instructions: {args.strip()}' + + return _prompt_result(input_text, prompt) + + +def _handle_pr_comments(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Fetch PR comments — prompt-type command using gh CLI.""" + pr_ref = args.strip() + + prompt = f"""Fetch and summarize comments from a GitHub pull request. + +{f'PR reference: {pr_ref}' if pr_ref else 'Find the current PR for this branch.'} + +Steps: +1. Get PR info: `gh pr view {pr_ref or ''} --json number,headRefName,headRepository` +2. Fetch PR-level comments: `gh api /repos/{{owner}}/{{repo}}/issues/{{number}}/comments --jq '.[] | {{body, user: .user.login, created_at}}'` +3. Fetch review comments: `gh api /repos/{{owner}}/{{repo}}/pulls/{{number}}/comments --jq '.[] | {{body, path, line, diff_hunk, user: .user.login}}'` +4. Present all comments with file/line context +5. Do not add explanatory text — just show the comments""" + + return _prompt_result(input_text, prompt) + + +def _handle_resume(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Resume a previous conversation.""" + import json as _json + + session_dir = agent.runtime_config.session_directory + if not session_dir.exists(): + return _local_result(input_text, 'No session directory found.') + + search_term = args.strip() + + # If a session ID is provided, show its info + if search_term: + session_path = session_dir / f'{search_term}.json' + if session_path.exists(): + try: + data = _json.loads(session_path.read_text(encoding='utf-8')) + msg_count = len(data.get('messages', [])) + return _local_result( + input_text, + f'Found session `{search_term}` with {msg_count} messages.\n' + f'To resume, restart with: `--resume {search_term}`', + ) + except Exception as exc: + return _local_result(input_text, f'Error reading session: {exc}') + + # List recent sessions + session_files = sorted( + session_dir.glob('*.json'), + key=lambda p: p.stat().st_mtime, + reverse=True, + )[:20] + + if not session_files: + return _local_result(input_text, 'No previous sessions found.') + + lines = ['## Recent Sessions', ''] + for sf in session_files: + try: + data = _json.loads(sf.read_text(encoding='utf-8')) + sid = sf.stem + msg_count = len(data.get('messages', [])) + model = data.get('model', 'unknown') + first_user = '' + for msg in data.get('messages', []): + if msg.get('role') == 'user' and msg.get('content', '').strip(): + first_user = msg['content'].strip()[:80] + if len(msg['content'].strip()) > 80: + first_user += '...' + break + line = f'- `{sid}` ({msg_count} msgs, {model})' + if first_user: + line += f': {first_user}' + lines.append(line) + except Exception: + lines.append(f'- `{sf.stem}` (error reading)') + + if search_term: + # Filter by search term + filtered = [ + l for l in lines + if search_term.lower() in l.lower() or l.startswith('#') + ] + if len(filtered) <= 2: + filtered.append(f'\nNo sessions matching "{search_term}".') + lines = filtered + + lines.append('\nTo resume: restart with `--resume `') + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_add_dir(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Add a new working directory.""" + from pathlib import Path as _Path + + path_str = args.strip() + if not path_str: + return _local_result(input_text, 'Usage: /add-dir ') + + path = _Path(path_str).resolve() + + if not path.exists(): + return _local_result(input_text, f'Path not found: {path}') + if not path.is_dir(): + return _local_result(input_text, f'Not a directory: {path}') + + cwd = agent.runtime_config.cwd + if path == cwd or str(path).startswith(str(cwd) + '/'): + return _local_result( + input_text, + f'Path is already within the working directory: {cwd}', + ) + + if not hasattr(agent, '_additional_directories'): + agent._additional_directories = [] + if path in agent._additional_directories: + return _local_result(input_text, f'Directory already added: {path}') + + agent._additional_directories.append(path) + return _local_result( + input_text, + f'Added working directory: {path}\n' + f'Tools can now access files in this directory.', + ) + + +def _handle_skills(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + """List available skills.""" + lines = ['## Available Skills', ''] + for spec in get_slash_command_specs(): + primary = f'/{spec.names[0]}' + lines.append(f'- `{primary}`: {spec.description}') + lines.extend(['', 'Use the Skill tool to invoke skills programmatically.']) + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_fast(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Toggle fast mode.""" + arg = args.strip().lower() + current = getattr(agent, '_fast_mode', False) + + if arg == 'on': + agent._fast_mode = True + return _local_result(input_text, 'Fast mode enabled.') + if arg == 'off': + agent._fast_mode = False + return _local_result(input_text, 'Fast mode disabled.') + if not arg: + agent._fast_mode = not current + state = 'enabled' if agent._fast_mode else 'disabled' + return _local_result(input_text, f'Fast mode {state}.') + return _local_result(input_text, 'Usage: /fast [on|off]') + + +def _handle_vim(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: + """Toggle between Vim and Normal editing modes.""" + current = getattr(agent, '_editor_mode', 'normal') + if current == 'emacs': + current = 'normal' + + new_mode = 'vim' if current == 'normal' else 'normal' + agent._editor_mode = new_mode + + if new_mode == 'vim': + hint = 'Use Escape key to toggle between INSERT and NORMAL modes.' + else: + hint = 'Using standard (readline) keyboard bindings.' + + return _local_result(input_text, f'Switched to {new_mode} mode. {hint}') + + +def _handle_rewind(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult: + """Rewind conversation to a previous message.""" + session = agent.last_session + if session is None: + return _local_result(input_text, 'No active session.') + + n_str = args.strip() + + if not n_str: + msgs = session.messages + lines = ['## Conversation History', ''] + for i, msg in enumerate(msgs): + role = msg.role.upper() + preview = msg.content[:60].replace('\n', ' ') + if len(msg.content) > 60: + preview += '...' + lines.append(f' {i}: [{role}] {preview}') + lines.append('') + lines.append('Usage: /rewind to truncate to that point.') + return _local_result(input_text, '\n'.join(lines)) + + try: + target = int(n_str) + except ValueError: + return _local_result(input_text, 'Usage: /rewind ') + + if target < 0 or target >= len(session.messages): + return _local_result( + input_text, + f'Invalid message number. Valid range: 0-{len(session.messages) - 1}', + ) + + removed_count = len(session.messages) - target - 1 + session.messages[:] = session.messages[:target + 1] + return _local_result( + input_text, + f'Rewound conversation to message {target}. Removed {removed_count} messages.', + ) + + +def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: + """Return a prompt-type result — the prompt gets sent to the model.""" + return SlashCommandResult( + handled=True, + should_query=True, + prompt=prompt, + transcript=({'role': 'user', 'content': input_text},), + ) + + def _local_result(input_text: str, output: str) -> SlashCommandResult: transcript = ( {'role': 'user', 'content': input_text}, diff --git a/src/agent_tools.py b/src/agent_tools.py index 484ee08..ec43107 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -1005,6 +1005,73 @@ def default_tool_registry() -> dict[str, AgentTool]: }, handler=_task_cancel, ), + AgentTool( + name='EnterPlanMode', + description=( + 'Enter plan mode. In plan mode, focus on exploring the codebase ' + 'and creating a plan rather than making changes. Use read-only ' + 'tools (Read, Grep, Glob) to investigate, then create a plan.' + ), + parameters={ + 'type': 'object', + 'properties': {}, + }, + handler=_enter_plan_mode, + ), + AgentTool( + name='ExitPlanMode', + description=( + 'Exit plan mode and return to normal execution mode. ' + 'Call this after you have finished exploring and have a plan ready.' + ), + parameters={ + 'type': 'object', + 'properties': {}, + }, + handler=_exit_plan_mode, + ), + AgentTool( + name='TaskOutput', + description=( + 'Get the output of a background task by its ID. ' + 'Use block=true (default) to wait for completion, ' + 'or block=false to check current status without waiting.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'task_id': { + 'type': 'string', + 'description': 'The task ID to get output from.', + }, + 'block': { + 'type': 'boolean', + 'description': 'Whether to wait for completion (default true).', + }, + 'timeout': { + 'type': 'number', + 'description': 'Max wait time in ms (0-600000, default 30000).', + }, + }, + 'required': ['task_id'], + }, + handler=_task_output, + ), + AgentTool( + name='TaskStop', + description='Stop a running background task by its ID.', + parameters={ + 'type': 'object', + 'properties': { + 'task_id': { + 'type': 'string', + 'description': 'The task ID to stop.', + }, + }, + 'required': ['task_id'], + }, + handler=_task_stop, + ), AgentTool( name='todo_write', description='Replace the current local runtime task list with a structured todo list.', @@ -2724,6 +2791,107 @@ def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str ) +def _enter_plan_mode(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + """Enter plan mode — focus on exploration and planning, not execution.""" + if getattr(context, '_plan_mode', False): + return 'Already in plan mode.' + context._plan_mode = True + return ( + 'Entered plan mode. Focus on exploring the codebase and creating a plan. ' + 'Use read-only tools (Read, Grep, Glob) to investigate. ' + 'When your plan is ready, call ExitPlanMode to return to normal mode.' + ) + + +def _exit_plan_mode(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + """Exit plan mode and return to normal execution.""" + if not getattr(context, '_plan_mode', False): + return 'Not currently in plan mode.' + context._plan_mode = False + return 'Exited plan mode. You can now execute changes based on your plan.' + + +def _task_output(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + """Get output from a background task.""" + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + block = arguments.get('block', True) + timeout_ms = arguments.get('timeout', 30000) + if not isinstance(timeout_ms, (int, float)): + timeout_ms = 30000 + timeout_ms = max(0, min(timeout_ms, 600000)) + + task = runtime.get_task(task_id) + if task is None: + raise ToolExecutionError(f'Task not found: {task_id}') + + if task.status in ('completed', 'cancelled', 'failed'): + output = getattr(task, 'output', '') or getattr(task, 'result', '') or '' + return ( + f'success\n' + f'{task_id}\n' + f'{task.status}\n' + f'{output}' + ) + + if not block: + return ( + f'not_ready\n' + f'{task_id}\n' + f'{task.status}' + ) + + # Blocking wait — poll until completion or timeout + import time as _time + deadline = _time.monotonic() + (timeout_ms / 1000.0) + while _time.monotonic() < deadline: + task = runtime.get_task(task_id) + if task is None or task.status in ('completed', 'cancelled', 'failed'): + break + _time.sleep(0.1) + + if task is None: + raise ToolExecutionError(f'Task disappeared: {task_id}') + + if task.status in ('completed', 'cancelled', 'failed'): + output = getattr(task, 'output', '') or getattr(task, 'result', '') or '' + return ( + f'success\n' + f'{task_id}\n' + f'{task.status}\n' + f'{output}' + ) + + return ( + f'timeout\n' + f'{task_id}\n' + f'{task.status}' + ) + + +def _task_stop(arguments: dict[str, Any], context: ToolExecutionContext) -> str: + """Stop a running background task.""" + runtime = _require_task_runtime(context) + task_id = _require_string(arguments, 'task_id') + + task = runtime.get_task(task_id) + if task is None: + raise ToolExecutionError(f'Task not found: {task_id}') + + if task.status not in ('pending', 'in_progress', 'running'): + raise ToolExecutionError( + f'Task {task_id} is not running (status: {task.status})' + ) + + # Cancel the task via the runtime + mutation = runtime.cancel_task(task_id, reason='Stopped by TaskStop tool') + description = getattr(task, 'title', '') or getattr(task, 'description', '') or task_id + return ( + f'Successfully stopped task: {task_id} ({description})', + {'action': 'task_stop', 'task_id': task_id}, + ) + + def _stream_bash( arguments: dict[str, Any], context: ToolExecutionContext, diff --git a/src/bundled_skills.py b/src/bundled_skills.py new file mode 100644 index 0000000..89c5536 --- /dev/null +++ b/src/bundled_skills.py @@ -0,0 +1,289 @@ +"""Bundled skill definitions — prompt-type skills invocable via the Skill tool. + +Mirrors the npm ``src/skills/bundled/`` module. + +Bundled skills differ from slash commands: +- They generate AI prompts sent to the model (prompt-type). +- They carry ``when_to_use`` guidance for model auto-invocation. +- They can restrict ``allowed_tools`` during execution. +- They appear in system-reminder skill listings for model discovery. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from .agent_runtime import LocalCodingAgent + + +@dataclass(frozen=True) +class BundledSkill: + """A bundled skill definition.""" + + name: str + description: str + when_to_use: str = '' + aliases: tuple[str, ...] = () + allowed_tools: tuple[str, ...] = () + user_invocable: bool = True + get_prompt: Callable[['LocalCodingAgent', str], str] = lambda _a, _args: '' + + +# --------------------------------------------------------------------------- +# Skill prompt generators +# --------------------------------------------------------------------------- + +def _simplify_prompt(agent: 'LocalCodingAgent', args: str) -> str: + """Generate the simplify review prompt.""" + cwd = str(agent.runtime_config.cwd) + diff = '' + try: + proc = subprocess.run( + ['git', 'diff', 'HEAD'], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + diff = proc.stdout.strip() + except Exception: + pass + + if not diff: + try: + proc = subprocess.run( + ['git', 'diff'], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + diff = proc.stdout.strip() + except Exception: + pass + + diff_section = f'```diff\n{diff}\n```' if diff else 'No changes detected — run `git diff` to verify.' + + return f"""Review the changed code for reuse, quality, and efficiency, then fix any issues found. + +## Changed Code + +{diff_section} + +## Review Checklist + +### Code Reuse +- Duplicated functions or logic that should be consolidated +- Redundant utilities or helpers +- Patterns that should use existing abstractions + +### Code Quality +- Redundant state or unnecessary variables +- Parameter sprawl (too many function arguments) +- Copy-paste code that should be refactored +- Leaky abstractions or wrong abstraction level +- Stringly-typed code that should use enums/types +- Overly nested conditionals + +### Efficiency +- N+1 query or call patterns +- Missed concurrency opportunities (parallel I/O) +- Hot-path bloat (unnecessary work in tight loops) +- Memory leaks or unbounded growth + +## Instructions + +1. Read the full diff above +2. For each category, identify concrete issues with file paths and line numbers +3. Fix each issue directly — do not just report them +4. After fixing, verify the changes compile/run correctly + +{f'Additional context: {args}' if args.strip() else ''}""" + + +def _verify_prompt(agent: 'LocalCodingAgent', args: str) -> str: + """Generate the verify prompt.""" + return f"""Verify that the recent code changes work correctly. + +## Instructions + +1. Identify what was changed (check `git diff` and `git status`) +2. Determine the appropriate verification strategy: + - **Unit tests**: Run existing tests, check for failures + - **Integration tests**: Run broader test suites if available + - **Manual verification**: Start the app/server and test the feature +3. Run the verification +4. Report the result clearly: + - PASS: All checks passed, feature works as expected + - FAIL: Describe what failed and why + - PARTIAL: Some checks passed, some need attention + +## Verification Strategy + +- For CLI tools: Run the command with test inputs +- For servers: Start the server, make test requests +- For libraries: Run the test suite +- For config changes: Validate the config loads correctly + +{f'Specific focus: {args}' if args.strip() else 'Verify the most recent changes.'}""" + + +def _debug_prompt(agent: 'LocalCodingAgent', args: str) -> str: + """Generate the debug prompt.""" + import os + + lines = ['## Debug Session Info', ''] + lines.append(f'Working directory: {agent.runtime_config.cwd}') + lines.append(f'Model: {agent.model_config.model}') + lines.append(f'Base URL: {agent.model_config.base_url}') + + session = agent.last_session + if session: + lines.append(f'Messages in session: {len(session.messages)}') + tool_count = sum(1 for m in session.messages if m.role == 'tool') + lines.append(f'Tool results: {tool_count}') + + usage = agent.cumulative_usage + lines.append(f'Total tokens used: {usage.total_tokens:,}') + lines.append(f'Cost: ${agent.cumulative_cost_usd:.4f}') + + # Check for debug log + debug_log = os.environ.get('CLAUDE_CODE_DEBUG_LOG', '') + if debug_log: + lines.append(f'Debug log: {debug_log}') + else: + lines.append('Debug logging: not enabled (set CLAUDE_CODE_DEBUG_LOG to enable)') + + return '\n'.join(lines) + + +def _update_config_prompt(agent: 'LocalCodingAgent', args: str) -> str: + """Generate the update-config prompt.""" + return f"""Help configure the agent settings. + +## Settings File Locations + +- **Global**: `~/.claude/settings.json` — applies to all projects +- **Project**: `.claude/settings.json` — project-specific, committed to git +- **Local**: `.claude/settings.local.json` — project-specific, gitignored + +## Configurable Settings + +### Hooks +Event-driven shell commands that run on tool use or lifecycle events: +- `PreToolUse` — runs before a tool executes (can block with exit code 2) +- `PostToolUse` — runs after a tool completes +- `PreCompact` — runs before conversation compaction + +Hook format: +```json +{{ + "hooks": {{ + "PreToolUse": [ + {{ + "matcher": "Bash", + "hooks": [ + {{ + "type": "command", + "command": "echo 'tool: $TOOL_NAME'" + }} + ] + }} + ] + }} +}} +``` + +### Permissions +Tool permission rules: +```json +{{ + "permissions": {{ + "allow": ["Read", "Grep", "Glob"], + "deny": ["Bash(rm:*)"] + }} +}} +``` + +### Environment Variables +```json +{{ + "env": {{ + "MY_VAR": "value" + }} +}} +``` + +{f'User request: {args}' if args.strip() else 'What would you like to configure?'}""" + + +# --------------------------------------------------------------------------- +# Skill registry +# --------------------------------------------------------------------------- + +BUNDLED_SKILLS: tuple[BundledSkill, ...] = ( + BundledSkill( + name='simplify', + description='Review changed code for reuse, quality, and efficiency, then fix issues.', + when_to_use='When the user asks to review, simplify, or clean up recent code changes.', + allowed_tools=('read_file', 'edit_file', 'write_file', 'bash', 'grep_search', 'glob_search'), + get_prompt=_simplify_prompt, + ), + BundledSkill( + name='verify', + description='Verify a code change works by running the app and tests.', + when_to_use='When the user asks to verify, test, or check that recent changes work.', + allowed_tools=('read_file', 'bash', 'grep_search', 'glob_search'), + get_prompt=_verify_prompt, + ), + BundledSkill( + name='debug', + description='Debug the current session — show diagnostics, token usage, and config.', + when_to_use='When the user asks to debug the agent session or check diagnostics.', + user_invocable=True, + get_prompt=_debug_prompt, + ), + BundledSkill( + name='update-config', + description='Configure settings via settings.json — hooks, permissions, env vars.', + when_to_use='When the user wants to configure hooks, permissions, or settings.', + aliases=('config-help',), + allowed_tools=('read_file',), + get_prompt=_update_config_prompt, + ), +) + + +def get_bundled_skills() -> tuple[BundledSkill, ...]: + """Return all registered bundled skills.""" + return BUNDLED_SKILLS + + +def find_bundled_skill(name: str) -> BundledSkill | None: + """Look up a bundled skill by name or alias.""" + lowered = name.lower() + for skill in BUNDLED_SKILLS: + if lowered == skill.name or lowered in skill.aliases: + return skill + return None + + +def format_skills_for_system_prompt(max_chars: int = 8000) -> str: + """Format bundled skills for inclusion in system-reminder messages. + + The model discovers available skills through this listing. + """ + lines = ['Available skills (invoke via Skill tool):'] + char_count = len(lines[0]) + + for skill in BUNDLED_SKILLS: + if not skill.user_invocable: + continue + entry = f'- {skill.name}: {skill.description}' + if skill.when_to_use: + entry += f' When to use: {skill.when_to_use}' + if len(entry) > 250: + entry = entry[:247] + '...' + if char_count + len(entry) + 1 > max_chars: + break + lines.append(entry) + char_count += len(entry) + 1 + + return '\n'.join(lines) diff --git a/src/compact.py b/src/compact.py index 2ed9651..2817533 100644 --- a/src/compact.py +++ b/src/compact.py @@ -447,15 +447,19 @@ def compact_conversation( agent: 'LocalCodingAgent', custom_instructions: str | None = None, ) -> CompactionResult: - """Perform an LLM-backed conversation compaction. + """Perform conversation compaction. - 1. Build the compact prompt (9-section template). - 2. Collect the session messages to summarise. - 3. Send them + the compact prompt to the model. - 4. On prompt-too-long, retry by dropping oldest API-round groups + Tries session-memory-based compaction first (free, no API call), + then falls back to LLM-backed compaction. + + 1. If no custom instructions, try session memory compact. + 2. Otherwise, build the compact prompt (9-section template). + 3. Collect the session messages to summarise. + 4. Send them + the compact prompt to the model. + 5. On prompt-too-long, retry by dropping oldest API-round groups (up to ``MAX_PTL_RETRIES`` attempts). - 5. Parse ```` from the response. - 6. Replace session messages with: + 6. Parse ```` from the response. + 7. Replace session messages with: boundary marker → summary user message → preserved tail. Returns a :class:`CompactionResult` with diagnostics. @@ -467,6 +471,34 @@ def compact_conversation( error=ERROR_NOT_ENOUGH_MESSAGES, ) + # --- Try session-memory-based compact first (no API call) --- + if custom_instructions is None: + from .session_memory_compact import try_session_memory_compaction + + last_summarized_id = getattr(agent, '_last_summarized_message_id', None) + sm_result = try_session_memory_compaction( + messages=list(session.messages), + model=agent.model_config.model, + last_summarized_message_id=last_summarized_id, + ) + if sm_result is not None: + # Apply the session-memory compaction to the session + prefix_count = 0 + for msg in session.messages: + if msg.metadata.get('kind') == 'compact_boundary': + prefix_count += 1 + else: + break + session.messages = ( + session.messages[:prefix_count] + + [sm_result.boundary_message] + + sm_result.summary_messages + + sm_result.messages_to_keep + ) + # Reset the summarized ID + agent._last_summarized_message_id = None + return sm_result + # ---- Determine which messages to compact vs preserve ---- preserve_count = max( getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1 diff --git a/src/session_memory_compact.py b/src/session_memory_compact.py new file mode 100644 index 0000000..89694e1 --- /dev/null +++ b/src/session_memory_compact.py @@ -0,0 +1,563 @@ +"""Session-memory-based compaction — uses an on-disk session memory summary +instead of an API call to compact the conversation. + +Mirrors the npm ``src/services/compact/sessionMemoryCompact.ts`` module. + +When session memory is available and up-to-date, this avoids the cost +of an API round-trip by reusing the background-maintained summary as +the compaction text. Falls back to ``None`` so the caller can use +the legacy API-based compact instead. +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .agent_context_usage import estimate_tokens +from .agent_session import AgentMessage + +if TYPE_CHECKING: + from .compact import CompactionResult + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +@dataclass +class SessionMemoryCompactConfig: + """Configuration for session-memory compaction thresholds.""" + + min_tokens: int = 10_000 + """Minimum tokens to preserve after compaction.""" + + min_text_block_messages: int = 5 + """Minimum number of messages with text content to keep.""" + + max_tokens: int = 40_000 + """Hard cap — never preserve more than this many tokens.""" + + max_section_tokens: int = 2_000 + """Maximum tokens per section in the session memory.""" + + max_total_tokens: int = 12_000 + """Maximum total tokens for the session memory summary.""" + + +DEFAULT_CONFIG = SessionMemoryCompactConfig() + +# --------------------------------------------------------------------------- +# Session memory file management +# --------------------------------------------------------------------------- + +SESSION_MEMORY_TEMPLATE_SECTIONS = ( + '## User Profile', + '## Project Context', + '## Key Decisions & Rationale', + '## Current Task Context', + '## Important Patterns & Preferences', + '## Learned Corrections', + '## Tool Usage Patterns', + '## Conversation Flow', + '## Open Questions & Uncertainties', +) +"""Section headers used in the session memory template.""" + + +def get_session_memory_dir() -> Path: + """Return the directory where session memory files are stored.""" + home = Path.home() + return home / '.claude' / 'session-memory' + + +def get_session_memory_path() -> Path: + """Return the path to the session memory file.""" + return get_session_memory_dir() / 'session.md' + + +def load_session_memory() -> str | None: + """Load session memory from disk, returning None if absent or empty.""" + path = get_session_memory_path() + if not path.exists(): + return None + try: + content = path.read_text(encoding='utf-8').strip() + if not content: + return None + return content + except (OSError, UnicodeDecodeError): + return None + + +def save_session_memory(content: str) -> None: + """Save session memory to disk.""" + path = get_session_memory_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding='utf-8') + + +def is_template_only(content: str) -> bool: + """Check if the session memory is just the empty template. + + Returns True if all sections are present but contain no real content + beyond the template headers and italic descriptions. + """ + lines = content.strip().splitlines() + for line in lines: + stripped = line.strip() + if not stripped: + continue + # Skip section headers + if stripped.startswith('##'): + continue + # Skip italic template descriptions + if stripped.startswith('*') and stripped.endswith('*'): + continue + if stripped.startswith('_') and stripped.endswith('_'): + continue + # Found non-template content + return False + return True + + +# --------------------------------------------------------------------------- +# Message analysis helpers +# --------------------------------------------------------------------------- + +def _has_text_content(msg: AgentMessage) -> bool: + """Check if a message has meaningful text content.""" + content = msg.content.strip() + if not content: + return False + if content == '[Old tool result content cleared]': + return False + return True + + +def _get_tool_result_ids(msg: AgentMessage) -> list[str]: + """Extract tool_call_ids from a tool-result message.""" + if msg.role == 'tool' and msg.tool_call_id: + return [msg.tool_call_id] + return [] + + +def _has_tool_use_with_ids(msg: AgentMessage, ids: set[str]) -> bool: + """Check if an assistant message contains tool_use blocks matching any of the given IDs.""" + if msg.role != 'assistant': + return False + tool_calls = msg.metadata.get('tool_calls') or msg.tool_calls + if not tool_calls: + return False + for tc in tool_calls: + tc_id = tc.get('id', '') if isinstance(tc, dict) else '' + if tc_id in ids: + return True + return False + + +# --------------------------------------------------------------------------- +# Index calculation +# --------------------------------------------------------------------------- + +def adjust_index_to_preserve_api_invariants( + messages: list[AgentMessage], + keep_from: int, +) -> int: + """Walk backwards to ensure kept messages maintain API-valid structure. + + Specifically: + 1. All tool_result messages must have corresponding tool_use messages. + 2. Assistant messages sharing the same message_id (thinking blocks) + must be kept together. + """ + if keep_from <= 0: + return 0 + + # Step 1: Ensure tool_use/tool_result pairs + kept_tool_result_ids: set[str] = set() + for msg in messages[keep_from:]: + for tid in _get_tool_result_ids(msg): + kept_tool_result_ids.add(tid) + + if kept_tool_result_ids: + idx = keep_from - 1 + while idx >= 0: + msg = messages[idx] + if _has_tool_use_with_ids(msg, kept_tool_result_ids): + keep_from = idx + # Include any new tool_results this brings in + for m in messages[idx:keep_from]: + for tid in _get_tool_result_ids(m): + kept_tool_result_ids.add(tid) + idx -= 1 + + # Step 2: Ensure thinking block continuity (same message_id) + kept_msg_ids: set[str] = set() + for msg in messages[keep_from:]: + if msg.role == 'assistant' and msg.message_id: + kept_msg_ids.add(msg.message_id) + + if kept_msg_ids: + idx = keep_from - 1 + while idx >= 0: + msg = messages[idx] + if msg.role == 'assistant' and msg.message_id in kept_msg_ids: + keep_from = idx + idx -= 1 + + return keep_from + + +def calculate_messages_to_keep_index( + messages: list[AgentMessage], + last_summarized_index: int, + model: str = '', + config: SessionMemoryCompactConfig | None = None, +) -> int: + """Calculate the index from which to preserve messages. + + Starts from ``last_summarized_index + 1`` and expands backwards + to meet the configured minimums (token count, text block count). + Stops if the hard max_tokens cap is reached. + """ + if config is None: + config = DEFAULT_CONFIG + + start_index = last_summarized_index + 1 + if start_index >= len(messages): + return len(messages) + + keep_from = start_index + token_count = 0 + text_block_count = 0 + + # Count forward from keep_from to end + for msg in messages[keep_from:]: + token_count += estimate_tokens(msg.content, model) + if _has_text_content(msg): + text_block_count += 1 + + # Expand backwards if minimums not met + idx = keep_from - 1 + while idx >= 0: + # Check if we've reached a compact boundary — don't go past it + if messages[idx].metadata.get('kind') == 'compact_boundary': + break + + msg_tokens = estimate_tokens(messages[idx].content, model) + + # Hard cap: stop if adding this would exceed max_tokens + if token_count + msg_tokens > config.max_tokens: + break + + # Expand backwards + keep_from = idx + token_count += msg_tokens + if _has_text_content(messages[idx]): + text_block_count += 1 + + # Check if minimums are met + if (token_count >= config.min_tokens + and text_block_count >= config.min_text_block_messages): + break + + idx -= 1 + + # Ensure API invariants + keep_from = adjust_index_to_preserve_api_invariants(messages, keep_from) + + return keep_from + + +# --------------------------------------------------------------------------- +# Session memory truncation +# --------------------------------------------------------------------------- + +def truncate_session_memory( + content: str, + config: SessionMemoryCompactConfig | None = None, +) -> tuple[str, bool]: + """Truncate session memory sections to fit within token limits. + + Returns ``(truncated_content, was_truncated)``. + """ + if config is None: + config = DEFAULT_CONFIG + + total_tokens = estimate_tokens(content, '') + if total_tokens <= config.max_total_tokens: + return content, False + + # Split by section headers and truncate each + lines = content.splitlines(keepends=True) + sections: list[list[str]] = [] + current_section: list[str] = [] + + for line in lines: + if line.strip().startswith('## ') and current_section: + sections.append(current_section) + current_section = [line] + else: + current_section.append(line) + if current_section: + sections.append(current_section) + + truncated_sections: list[str] = [] + was_truncated = False + + for section in sections: + section_text = ''.join(section) + section_tokens = estimate_tokens(section_text, '') + if section_tokens > config.max_section_tokens: + # Truncate to fit within section limit + truncated_lines: list[str] = [] + running_tokens = 0 + for line in section: + line_tokens = estimate_tokens(line, '') + if running_tokens + line_tokens > config.max_section_tokens: + truncated_lines.append('...(truncated)\n') + was_truncated = True + break + truncated_lines.append(line) + running_tokens += line_tokens + truncated_sections.append(''.join(truncated_lines)) + else: + truncated_sections.append(section_text) + + result = ''.join(truncated_sections) + + # Check total again + if estimate_tokens(result, '') > config.max_total_tokens: + was_truncated = True + + return result, was_truncated + + +# --------------------------------------------------------------------------- +# Core session memory compaction +# --------------------------------------------------------------------------- + +def try_session_memory_compaction( + messages: list[AgentMessage], + model: str = '', + last_summarized_message_id: str | None = None, + auto_compact_threshold: int | None = None, + config: SessionMemoryCompactConfig | None = None, +) -> 'CompactionResult | None': + """Attempt session-memory-based compaction. + + Returns a :class:`CompactionResult` if session memory is available + and the compaction succeeds, or ``None`` to signal the caller should + fall back to API-based compaction. + + Parameters + ---------- + messages: + The current session messages. + model: + Model name for token estimation. + last_summarized_message_id: + The message_id of the last message that was included in the + session memory summary. Messages after this are preserved. + auto_compact_threshold: + If provided, return None if post-compact tokens exceed this. + config: + Compaction configuration thresholds. + """ + from .compact import CompactionResult + + if config is None: + config = DEFAULT_CONFIG + + # Check environment gates + if os.environ.get('DISABLE_CLAUDE_CODE_SM_COMPACT'): + return None + + # Load session memory + session_memory = load_session_memory() + if session_memory is None: + return None + + if is_template_only(session_memory): + return None + + # Find the boundary message + last_summarized_index: int | None = None + + if last_summarized_message_id: + for i, msg in enumerate(messages): + if msg.message_id == last_summarized_message_id: + last_summarized_index = i + break + + if last_summarized_index is None: + # No boundary found — can't determine what's already summarized + # Fall back to legacy compact + return None + + # Calculate which messages to preserve + keep_from = calculate_messages_to_keep_index( + messages, last_summarized_index, model=model, config=config, + ) + + messages_to_keep = list(messages[keep_from:]) + + # Filter out old compact boundaries from kept messages + messages_to_keep = [ + m for m in messages_to_keep + if m.metadata.get('kind') != 'compact_boundary' + ] + + # Truncate session memory if needed + truncated_memory, was_truncated = truncate_session_memory( + session_memory, config=config, + ) + + # Build the compaction result + pre_tokens = sum(estimate_tokens(m.content, model) for m in messages) + + boundary = AgentMessage( + role='user', + content=( + '\n' + f'Earlier conversation was compacted using session memory. ' + f'{len(messages) - len(messages_to_keep)} messages summarized.\n' + '' + ), + message_id='compact_boundary', + metadata={ + 'kind': 'compact_boundary', + 'source': 'session_memory', + 'pre_compact_token_count': pre_tokens, + }, + ) + + summary_content = ( + 'Here is a summary of our conversation so far:\n\n' + f'{truncated_memory}' + ) + if was_truncated: + memory_path = get_session_memory_path() + summary_content += ( + f'\n\n(Session memory was truncated. ' + f'Full version at: {memory_path})' + ) + + summary_msg = AgentMessage( + role='user', + content=summary_content, + message_id='compact_summary', + metadata={ + 'kind': 'compact_summary', + 'is_compact_summary': True, + 'source': 'session_memory', + }, + ) + + post_messages = [boundary, summary_msg] + messages_to_keep + post_tokens = sum(estimate_tokens(m.content, model) for m in post_messages) + + # Check threshold + if auto_compact_threshold is not None and post_tokens > auto_compact_threshold: + return None + + return CompactionResult( + boundary_message=boundary, + summary_messages=[summary_msg], + messages_to_keep=messages_to_keep, + pre_compact_token_count=pre_tokens, + post_compact_token_count=post_tokens, + true_post_compact_token_count=post_tokens, + summary_text=truncated_memory, + ) + + +# --------------------------------------------------------------------------- +# Session memory extraction (lightweight background summary updater) +# --------------------------------------------------------------------------- + +SESSION_MEMORY_EXTRACTION_PROMPT = """Analyze the conversation so far and update the session memory. +Extract key information into these sections: + +## User Profile +Who the user is, their role, expertise level, and preferences. + +## Project Context +What project/codebase is being worked on, its structure, and tech stack. + +## Key Decisions & Rationale +Important decisions made during this session and why. + +## Current Task Context +What the user is currently working on and the state of that work. + +## Important Patterns & Preferences +Coding style, conventions, or preferences observed. + +## Learned Corrections +Mistakes made and corrections applied — things to avoid repeating. + +## Tool Usage Patterns +Which tools work well, preferred approaches for common tasks. + +## Conversation Flow +Major topic transitions and how the conversation has progressed. + +## Open Questions & Uncertainties +Unresolved questions or areas of ambiguity. + +Write concisely. Focus on information that would be useful for continuing +this conversation after a context reset. Omit sections with no content.""" + + +def extract_session_memory_from_messages( + messages: list[AgentMessage], + model: str = '', +) -> str: + """Build a session memory summary from conversation messages. + + This is a lightweight local extraction — it walks the messages and + builds a structured summary without an API call. For richer + summaries, the full LLM-based extraction should be used. + """ + user_messages: list[str] = [] + tool_names_used: set[str] = set() + file_paths: set[str] = set() + corrections: list[str] = [] + + for msg in messages: + if msg.role == 'user' and _has_text_content(msg): + user_messages.append(msg.content[:200]) + elif msg.role == 'tool' and msg.name: + tool_names_used.add(msg.name) + path = msg.metadata.get('path') + if isinstance(path, str): + file_paths.add(path) + + sections: list[str] = [] + + if user_messages: + sections.append('## Current Task Context') + # Use recent user messages as task context + recent = user_messages[-5:] + for um in recent: + sections.append(f'- {um[:100]}') + + if tool_names_used: + sections.append('\n## Tool Usage Patterns') + sections.append(f'Tools used: {", ".join(sorted(tool_names_used))}') + + if file_paths: + sections.append('\n## Project Context') + sections.append(f'Files accessed: {", ".join(sorted(list(file_paths)[:20]))}') + + sections.append('\n## Conversation Flow') + sections.append(f'Total messages: {len(messages)}') + sections.append( + f'User messages: {sum(1 for m in messages if m.role == "user")}' + ) + + return '\n'.join(sections)