Created src/prompt_constants.py (550+ lines) porting all constants from npm src/constants/:
┌──────────────────┬───────────────────────────┬───────────────────────────────────────────────┐ │ Category │ npm Source │ Items │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Product metadata │ product.ts │ URLs, base URLs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ System prefixes │ system.ts │ 3 prompt prefixes │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Cyber risk │ cyberRiskInstruction.ts │ Safety instruction │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ API limits │ apiLimits.ts │ 10 image/PDF/media limits │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Tool limits │ toolLimits.ts │ 6 result size constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Spinner verbs │ spinnerVerbs.ts │ 187 whimsical gerunds │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Completion verbs │ turnCompletionVerbs.ts │ 8 past-tense verbs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Figures/symbols │ figures.ts │ 25 Unicode UI symbols │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ XML tags │ xml.ts │ 30+ tag constants │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Messages │ messages.ts │ NO_CONTENT_MESSAGE │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Date utilities │ common.ts │ 4 functions │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Section caching │ systemPromptSections.ts │ Memoized/volatile sections │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Output styles │ outputStyles.ts │ 3 built-in configs │ ├──────────────────┼───────────────────────────┼───────────────────────────────────────────────┤ │ Prompt helpers │ prompts.ts │ Knowledge cutoff, language, scratchpad, hooks │ └──────────────────┴───────────────────────────┴───────────────────────────────────────────────┘ 91 new tests in tests/test_prompt_constants.py. All 17 SQL todos done.
This commit is contained in:
@@ -294,6 +294,71 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Clear ephemeral Python runtime state for this process.',
|
||||
handler=_handle_clear,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('compact',),
|
||||
description='Summarise and compact the conversation to free context space.',
|
||||
handler=_handle_compact,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('cost',),
|
||||
description='Show the total cost and duration of the current session.',
|
||||
handler=_handle_cost,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('exit', 'quit'),
|
||||
description='Exit the REPL.',
|
||||
handler=_handle_exit,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('diff',),
|
||||
description='View uncommitted changes (git diff) in the working directory.',
|
||||
handler=_handle_diff,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('files',),
|
||||
description='List files currently loaded in the session context.',
|
||||
handler=_handle_files,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('copy',),
|
||||
description='Copy the last assistant response to a temp file.',
|
||||
handler=_handle_copy,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('export',),
|
||||
description='Export the conversation to a text file.',
|
||||
handler=_handle_export,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('stats',),
|
||||
description='Show session usage statistics.',
|
||||
handler=_handle_stats,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('tag',),
|
||||
description='Add or remove a searchable tag on the current session.',
|
||||
handler=_handle_tag,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('rename',),
|
||||
description='Rename the current conversation.',
|
||||
handler=_handle_rename,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('branch',),
|
||||
description='Create a fork/branch of the current conversation.',
|
||||
handler=_handle_branch,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('effort',),
|
||||
description='Show or set the model effort level (low, medium, high, max, auto).',
|
||||
handler=_handle_effort,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('doctor',),
|
||||
description='Diagnose and verify the claw-code installation and settings.',
|
||||
handler=_handle_doctor,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -620,6 +685,417 @@ def _handle_clear(agent: 'LocalCodingAgent', _args: str, input_text: str) -> Sla
|
||||
)
|
||||
|
||||
|
||||
def _handle_compact(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
from .compact import compact_conversation
|
||||
|
||||
custom_instructions = args.strip() if args.strip() else None
|
||||
result = compact_conversation(agent, custom_instructions)
|
||||
|
||||
if result.error:
|
||||
return _local_result(input_text, f'Compact failed: {result.error}')
|
||||
|
||||
lines = ['Conversation compacted.']
|
||||
if result.pre_compact_token_count:
|
||||
lines.append(
|
||||
f' Tokens before: ~{result.pre_compact_token_count:,} '
|
||||
f'→ after: ~{result.post_compact_token_count:,}'
|
||||
)
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_cost(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
usage = agent.cumulative_usage
|
||||
cost = agent.cumulative_cost_usd
|
||||
|
||||
def _fmt_cost(usd: float) -> str:
|
||||
if usd < 0.01:
|
||||
return f'${usd:.4f}'
|
||||
return f'${usd:.2f}'
|
||||
|
||||
lines = [
|
||||
f'Total cost: {_fmt_cost(cost)}',
|
||||
f'Total input tokens: {usage.input_tokens:,}',
|
||||
f'Total output tokens: {usage.output_tokens:,}',
|
||||
]
|
||||
if usage.cache_read_input_tokens:
|
||||
lines.append(f'Cache read tokens: {usage.cache_read_input_tokens:,}')
|
||||
if usage.cache_creation_input_tokens:
|
||||
lines.append(f'Cache creation tokens: {usage.cache_creation_input_tokens:,}')
|
||||
if usage.reasoning_tokens:
|
||||
lines.append(f'Reasoning tokens: {usage.reasoning_tokens:,}')
|
||||
lines.append(f'Total tokens: {usage.total_tokens:,}')
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_exit(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
import random
|
||||
import sys
|
||||
|
||||
messages = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']
|
||||
output = random.choice(messages)
|
||||
# Build the result first so the transcript is recorded, then exit.
|
||||
result = _local_result(input_text, output)
|
||||
print(output)
|
||||
sys.exit(0)
|
||||
return result # unreachable, but satisfies the type checker
|
||||
|
||||
|
||||
def _handle_diff(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
import subprocess
|
||||
|
||||
cwd = str(agent.runtime_config.cwd)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
['git', 'diff'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
diff_output = proc.stdout.strip()
|
||||
if not diff_output:
|
||||
# Also check staged changes
|
||||
proc_staged = subprocess.run(
|
||||
['git', 'diff', '--staged'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
diff_output = proc_staged.stdout.strip()
|
||||
if not diff_output:
|
||||
return _local_result(input_text, 'No uncommitted changes.')
|
||||
return _local_result(input_text, f'Staged changes:\n{diff_output}')
|
||||
return _local_result(input_text, diff_output)
|
||||
except FileNotFoundError:
|
||||
return _local_result(input_text, 'git is not available.')
|
||||
except subprocess.TimeoutExpired:
|
||||
return _local_result(input_text, 'git diff timed out.')
|
||||
except Exception as exc:
|
||||
return _local_result(input_text, f'Error running git diff: {exc}')
|
||||
|
||||
|
||||
def _handle_files(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
"""List files loaded in the session context (from readFileState)."""
|
||||
session = agent.last_session
|
||||
if session is None:
|
||||
return _local_result(input_text, 'No active session.')
|
||||
|
||||
# Collect file paths mentioned in tool results
|
||||
file_paths: list[str] = []
|
||||
for msg in session.messages:
|
||||
if msg.role == 'tool' and msg.name in ('Read', 'read_file', 'ReadFile'):
|
||||
# Extract path from content or metadata
|
||||
path = msg.metadata.get('path')
|
||||
if isinstance(path, str):
|
||||
file_paths.append(path)
|
||||
elif msg.content and msg.content.startswith('/'):
|
||||
# First line might be the path
|
||||
first_line = msg.content.split('\n', 1)[0].strip()
|
||||
if '/' in first_line and len(first_line) < 256:
|
||||
file_paths.append(first_line)
|
||||
|
||||
# Also look at tool_calls in assistant messages
|
||||
for msg in session.messages:
|
||||
if msg.role == 'assistant' and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
func = tc.get('function', {}) if isinstance(tc, dict) else {}
|
||||
if func.get('name') in ('Read', 'read_file', 'ReadFile', 'View'):
|
||||
import json as _json
|
||||
try:
|
||||
args = _json.loads(func.get('arguments', '{}'))
|
||||
path = args.get('file_path') or args.get('path')
|
||||
if isinstance(path, str):
|
||||
file_paths.append(path)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Deduplicate preserving order
|
||||
seen: set[str] = set()
|
||||
unique_paths: list[str] = []
|
||||
for p in file_paths:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique_paths.append(p)
|
||||
|
||||
if not unique_paths:
|
||||
return _local_result(input_text, 'No files loaded in context.')
|
||||
|
||||
cwd = str(agent.runtime_config.cwd)
|
||||
relative_paths = []
|
||||
for p in unique_paths:
|
||||
if p.startswith(cwd):
|
||||
relative_paths.append(p[len(cwd):].lstrip('/'))
|
||||
else:
|
||||
relative_paths.append(p)
|
||||
|
||||
lines = [f'Files in context ({len(relative_paths)}):']
|
||||
for p in relative_paths:
|
||||
lines.append(f' {p}')
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_copy(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Copy the last assistant response to a temp file."""
|
||||
import tempfile as _tempfile
|
||||
|
||||
session = agent.last_session
|
||||
if session is None:
|
||||
return _local_result(input_text, 'No active session.')
|
||||
|
||||
# Find the Nth most recent assistant message (default N=0 = latest)
|
||||
n = 0
|
||||
if args.strip().isdigit():
|
||||
n = min(int(args.strip()), 20)
|
||||
|
||||
assistant_messages = [
|
||||
msg for msg in session.messages
|
||||
if msg.role == 'assistant' and msg.content.strip()
|
||||
]
|
||||
if not assistant_messages:
|
||||
return _local_result(input_text, 'No assistant responses to copy.')
|
||||
|
||||
index = len(assistant_messages) - 1 - n
|
||||
if index < 0:
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Only {len(assistant_messages)} assistant responses available.',
|
||||
)
|
||||
|
||||
content = assistant_messages[index].content
|
||||
|
||||
# Write to temp file
|
||||
from pathlib import Path as _Path
|
||||
tmp_dir = _Path(_tempfile.gettempdir()) / 'claw-code'
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = tmp_dir / 'response.md'
|
||||
out_path.write_text(content, encoding='utf-8')
|
||||
|
||||
char_count = len(content)
|
||||
line_count = content.count('\n') + 1
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Copied {char_count:,} chars ({line_count} lines) to {out_path}',
|
||||
)
|
||||
|
||||
|
||||
def _handle_export(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Export the conversation transcript to a text file."""
|
||||
from pathlib import Path as _Path
|
||||
import time as _time
|
||||
|
||||
session = agent.last_session
|
||||
if session is None:
|
||||
return _local_result(input_text, 'No active session to export.')
|
||||
|
||||
# Build plain-text transcript
|
||||
lines: list[str] = []
|
||||
for msg in session.messages:
|
||||
label = msg.role.upper()
|
||||
if msg.role == 'tool' and msg.name:
|
||||
label = f'TOOL:{msg.name}'
|
||||
lines.append(f'--- {label} ---')
|
||||
lines.append(msg.content)
|
||||
lines.append('')
|
||||
text = '\n'.join(lines)
|
||||
|
||||
# Determine output path
|
||||
filename = args.strip()
|
||||
if not filename:
|
||||
timestamp = _time.strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'conversation_{timestamp}.txt'
|
||||
if not filename.endswith('.txt'):
|
||||
filename += '.txt'
|
||||
|
||||
out_path = _Path(str(agent.runtime_config.cwd)) / filename
|
||||
out_path.write_text(text, encoding='utf-8')
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Exported {len(session.messages)} messages to {out_path}',
|
||||
)
|
||||
|
||||
|
||||
def _handle_stats(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Show session usage statistics."""
|
||||
usage = agent.cumulative_usage
|
||||
cost = agent.cumulative_cost_usd
|
||||
|
||||
session = agent.last_session
|
||||
msg_count = len(session.messages) if session else 0
|
||||
user_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'user')
|
||||
assistant_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'assistant')
|
||||
tool_msgs = sum(1 for m in (session.messages if session else []) if m.role == 'tool')
|
||||
|
||||
lines = [
|
||||
'## Session Statistics',
|
||||
'',
|
||||
f'Messages: {msg_count} total ({user_msgs} user, {assistant_msgs} assistant, {tool_msgs} tool)',
|
||||
f'Input tokens: {usage.input_tokens:,}',
|
||||
f'Output tokens: {usage.output_tokens:,}',
|
||||
f'Total tokens: {usage.total_tokens:,}',
|
||||
f'Cost: ${cost:.4f}',
|
||||
f'Model: {agent.model_config.model}',
|
||||
]
|
||||
if agent.active_session_id:
|
||||
lines.append(f'Session ID: {agent.active_session_id}')
|
||||
return _local_result(input_text, '\n'.join(lines))
|
||||
|
||||
|
||||
def _handle_tag(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Add or remove a tag on the current session."""
|
||||
tag = args.strip()
|
||||
if not tag:
|
||||
# Show current tags
|
||||
tags = getattr(agent, '_session_tags', set())
|
||||
if tags:
|
||||
return _local_result(input_text, f'Session tags: {", ".join(sorted(tags))}')
|
||||
return _local_result(input_text, 'No tags set. Usage: /tag <tag-name>')
|
||||
|
||||
# Toggle tag
|
||||
if not hasattr(agent, '_session_tags'):
|
||||
agent._session_tags = set()
|
||||
|
||||
if tag in agent._session_tags:
|
||||
agent._session_tags.discard(tag)
|
||||
return _local_result(input_text, f'Removed tag: {tag}')
|
||||
agent._session_tags.add(tag)
|
||||
return _local_result(input_text, f'Added tag: {tag}')
|
||||
|
||||
|
||||
def _handle_rename(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Rename the current conversation."""
|
||||
name = args.strip()
|
||||
if not name:
|
||||
return _local_result(input_text, 'Usage: /rename <name>')
|
||||
|
||||
if not hasattr(agent, '_session_name'):
|
||||
agent._session_name = None
|
||||
agent._session_name = name
|
||||
return _local_result(input_text, f'Session renamed to: {name}')
|
||||
|
||||
|
||||
def _handle_branch(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Create a fork/branch of the current conversation."""
|
||||
import json as _json
|
||||
from uuid import uuid4
|
||||
|
||||
session = agent.last_session
|
||||
if session is None:
|
||||
return _local_result(input_text, 'No active session to branch.')
|
||||
|
||||
branch_name = args.strip() or f'branch-{uuid4().hex[:8]}'
|
||||
new_session_id = uuid4().hex
|
||||
|
||||
# Save a copy of the current transcript as a new session file
|
||||
session_dir = agent.runtime_config.session_directory
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_path = session_dir / f'{new_session_id}.json'
|
||||
|
||||
transcript = [msg.to_transcript_entry() for msg in session.messages]
|
||||
branch_data = {
|
||||
'session_id': new_session_id,
|
||||
'branch_name': branch_name,
|
||||
'branched_from': agent.active_session_id,
|
||||
'messages': transcript,
|
||||
'model': agent.model_config.model,
|
||||
}
|
||||
|
||||
try:
|
||||
session_path.write_text(_json.dumps(branch_data, indent=2), encoding='utf-8')
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Created branch "{branch_name}" (session: {new_session_id})\n'
|
||||
f'Saved to: {session_path}',
|
||||
)
|
||||
except Exception as exc:
|
||||
return _local_result(input_text, f'Error creating branch: {exc}')
|
||||
|
||||
|
||||
def _handle_effort(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Show or set the model effort level."""
|
||||
import os
|
||||
|
||||
valid_levels = ('low', 'medium', 'high', 'max', 'auto')
|
||||
current = getattr(agent.runtime_config, 'effort_level', None)
|
||||
env_override = os.environ.get('CLAUDE_CODE_EFFORT_LEVEL')
|
||||
|
||||
if not args.strip():
|
||||
level = current or env_override or 'auto'
|
||||
msg = f'Current effort level: {level}'
|
||||
if env_override:
|
||||
msg += f' (from CLAUDE_CODE_EFFORT_LEVEL env var)'
|
||||
return _local_result(input_text, msg)
|
||||
|
||||
level = args.strip().lower()
|
||||
if level not in valid_levels:
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Invalid effort level: {level}\nValid levels: {", ".join(valid_levels)}',
|
||||
)
|
||||
|
||||
if env_override:
|
||||
return _local_result(
|
||||
input_text,
|
||||
f'Cannot change effort level — overridden by '
|
||||
f'CLAUDE_CODE_EFFORT_LEVEL={env_override}',
|
||||
)
|
||||
|
||||
# Store effort level on the runtime config
|
||||
object.__setattr__(agent.runtime_config, 'effort_level', level)
|
||||
return _local_result(input_text, f'Set effort level to: {level}')
|
||||
|
||||
|
||||
def _handle_doctor(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
"""Diagnose and verify the claw-code installation."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
|
||||
checks: list[str] = []
|
||||
|
||||
# Python version
|
||||
py_ver = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
|
||||
ok = sys.version_info >= (3, 10)
|
||||
checks.append(f'{"✓" if ok else "✗"} Python version: {py_ver} (need ≥3.10)')
|
||||
|
||||
# Git available
|
||||
git_ok = shutil.which('git') is not None
|
||||
checks.append(f'{"✓" if git_ok else "✗"} git: {"found" if git_ok else "NOT FOUND"}')
|
||||
|
||||
# Model config
|
||||
checks.append(f'✓ Model: {agent.model_config.model}')
|
||||
checks.append(f'✓ Base URL: {agent.model_config.base_url}')
|
||||
|
||||
# Working directory
|
||||
cwd = agent.runtime_config.cwd
|
||||
checks.append(f'✓ Working directory: {cwd}')
|
||||
checks.append(f'{"✓" if cwd.exists() else "✗"} Working directory exists: {cwd.exists()}')
|
||||
|
||||
# Session directory
|
||||
sess_dir = agent.runtime_config.session_directory
|
||||
checks.append(f'✓ Session directory: {sess_dir}')
|
||||
checks.append(f'{"✓" if sess_dir.exists() else "○"} Session directory exists: {sess_dir.exists()}')
|
||||
|
||||
# API key
|
||||
has_key = bool(agent.model_config.api_key)
|
||||
checks.append(f'{"✓" if has_key else "✗"} API key: {"set" if has_key else "NOT SET"}')
|
||||
|
||||
# Tools
|
||||
tool_count = len(agent.tool_registry) if agent.tool_registry else 0
|
||||
checks.append(f'✓ Registered tools: {tool_count}')
|
||||
|
||||
# Memory files (CLAUDE.md)
|
||||
claude_md = cwd / 'CLAUDE.md'
|
||||
checks.append(
|
||||
f'{"✓" if claude_md.exists() else "○"} CLAUDE.md: '
|
||||
f'{"found" if claude_md.exists() else "not found (optional)"}'
|
||||
)
|
||||
|
||||
output = '## Doctor Report\n\n' + '\n'.join(checks)
|
||||
return _local_result(input_text, output)
|
||||
|
||||
|
||||
def _local_result(input_text: str, output: str) -> SlashCommandResult:
|
||||
transcript = (
|
||||
{'role': 'user', 'content': input_text},
|
||||
|
||||
Reference in New Issue
Block a user