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:
@@ -99,6 +99,8 @@ class LocalCodingAgent:
|
||||
worktree_runtime: WorktreeRuntime | None = None
|
||||
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||||
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
||||
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
|
||||
cumulative_cost_usd: float = field(default=0.0, init=False, repr=False)
|
||||
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||||
last_session_path: str | None = field(default=None, init=False, repr=False)
|
||||
managed_agent_id: str | None = field(default=None, init=False, repr=False)
|
||||
@@ -321,6 +323,7 @@ class LocalCodingAgent:
|
||||
scratchpad_directory=scratchpad_directory,
|
||||
existing_file_history=(),
|
||||
)
|
||||
self._accumulate_usage(result)
|
||||
self._finalize_managed_agent(result)
|
||||
return result
|
||||
|
||||
@@ -357,6 +360,7 @@ class LocalCodingAgent:
|
||||
scratchpad_directory=scratchpad_directory,
|
||||
existing_file_history=stored_session.file_history,
|
||||
)
|
||||
self._accumulate_usage(result)
|
||||
self._finalize_managed_agent(result)
|
||||
return result
|
||||
|
||||
@@ -3363,6 +3367,11 @@ class LocalCodingAgent:
|
||||
)
|
||||
self.resume_source_session_id = None
|
||||
|
||||
def _accumulate_usage(self, result: AgentRunResult) -> None:
|
||||
"""Add a run's usage to the cumulative session totals."""
|
||||
self.cumulative_usage = self.cumulative_usage + result.usage
|
||||
self.cumulative_cost_usd += result.total_cost_usd
|
||||
|
||||
def _refresh_runtime_views_for_tool_result(
|
||||
self,
|
||||
tool_name: str,
|
||||
|
||||
@@ -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},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+438
@@ -0,0 +1,438 @@
|
||||
"""Conversation compaction service.
|
||||
|
||||
Mirrors the npm ``src/services/compact/compact.ts`` and
|
||||
``src/services/compact/prompt.ts`` modules. Provides:
|
||||
|
||||
- The 9-section summarisation prompt (``get_compact_prompt``).
|
||||
- XML-tag formatting/stripping (``format_compact_summary``).
|
||||
- The post-compact user summary message builder
|
||||
(``get_compact_user_summary_message``).
|
||||
- The core ``compact_conversation`` entry point that an
|
||||
``/compact`` slash command or auto-compact subsystem can call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .agent_context_usage import estimate_tokens
|
||||
from .agent_session import AgentMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent_runtime import LocalCodingAgent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTOCOMPACT_BUFFER_TOKENS = 13_000
|
||||
"""How many tokens to reserve below the effective context window before
|
||||
auto-compact fires (same as the npm ``AUTOCOMPACT_BUFFER_TOKENS``)."""
|
||||
|
||||
ERROR_NOT_ENOUGH_MESSAGES = 'Not enough messages to compact.'
|
||||
ERROR_INCOMPLETE_RESPONSE = (
|
||||
'The summary response was incomplete. '
|
||||
'The conversation was not compacted.'
|
||||
)
|
||||
ERROR_USER_ABORT = 'Compaction canceled.'
|
||||
|
||||
MAX_COMPACT_FAILURES = 3
|
||||
"""Circuit-breaker – stop retrying auto-compact after this many consecutive
|
||||
failures (mirrors the npm implementation)."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt construction (npm ``src/services/compact/prompt.ts``)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NO_TOOLS_PREAMBLE = """\
|
||||
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
||||
|
||||
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
|
||||
- You already have all the context you need in the conversation above.
|
||||
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
|
||||
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
|
||||
|
||||
"""
|
||||
|
||||
_DETAILED_ANALYSIS_INSTRUCTION = """\
|
||||
Before providing your final summary, wrap your analysis in <analysis> tags to \
|
||||
organize your thoughts and ensure you've covered all necessary points. In your \
|
||||
analysis process:
|
||||
|
||||
1. Chronologically analyze each message and section of the conversation. \
|
||||
For each section thoroughly identify:
|
||||
- The user's explicit requests and intents
|
||||
- Your approach to addressing the user's requests
|
||||
- Key decisions, technical concepts and code patterns
|
||||
- Specific details like:
|
||||
- file names
|
||||
- full code snippets
|
||||
- function signatures
|
||||
- file edits
|
||||
- Errors that you ran into and how you fixed them
|
||||
- Pay special attention to specific user feedback that you received, \
|
||||
especially if the user told you to do something differently.
|
||||
2. Double-check for technical accuracy and completeness, addressing each \
|
||||
required element thoroughly."""
|
||||
|
||||
_BASE_COMPACT_PROMPT = f"""\
|
||||
Your task is to create a detailed summary of the conversation so far, paying \
|
||||
close attention to the user's explicit requests and your previous actions.
|
||||
This summary should be thorough in capturing technical details, code patterns, \
|
||||
and architectural decisions that would be essential for continuing development \
|
||||
work without losing context.
|
||||
|
||||
{_DETAILED_ANALYSIS_INSTRUCTION}
|
||||
|
||||
Your summary should include the following sections:
|
||||
|
||||
1. Primary Request and Intent: Capture all of the user's explicit requests \
|
||||
and intents in detail
|
||||
2. Key Technical Concepts: List all important technical concepts, technologies, \
|
||||
and frameworks discussed.
|
||||
3. Files and Code Sections: Enumerate specific files and code sections examined, \
|
||||
modified, or created. Pay special attention to the most recent messages and \
|
||||
include full code snippets where applicable and include a summary of why this \
|
||||
file read or edit is important.
|
||||
4. Errors and fixes: List all errors that you ran into, and how you fixed them. \
|
||||
Pay special attention to specific user feedback that you received, especially if \
|
||||
the user told you to do something differently.
|
||||
5. Problem Solving: Document problems solved and any ongoing troubleshooting \
|
||||
efforts.
|
||||
6. All user messages: List ALL user messages that are not tool results. These \
|
||||
are critical for understanding the users' feedback and changing intent.
|
||||
7. Pending Tasks: Outline any pending tasks that you have explicitly been asked \
|
||||
to work on.
|
||||
8. Current Work: Describe in detail precisely what was being worked on \
|
||||
immediately before this summary request, paying special attention to the most \
|
||||
recent messages from both user and assistant. Include file names and code \
|
||||
snippets where applicable.
|
||||
9. Optional Next Step: List the next step that you will take that is related to \
|
||||
the most recent work you were doing. IMPORTANT: ensure that this step is \
|
||||
DIRECTLY in line with the user's most recent explicit requests, and the task \
|
||||
you were working on immediately before this summary request. If your last task \
|
||||
was concluded, then only list next steps if they are explicitly in line with the \
|
||||
users request. Do not start on tangential requests or really old requests that \
|
||||
were already completed without confirming with the user first.
|
||||
If there is a next step, include direct quotes from the \
|
||||
most recent conversation showing exactly what task you were working on and where \
|
||||
you left off. This should be verbatim to ensure there's no drift in task \
|
||||
interpretation.
|
||||
|
||||
Here's an example of how your output should be structured:
|
||||
|
||||
<example>
|
||||
<analysis>
|
||||
[Your thought process, ensuring all points are covered thoroughly and accurately]
|
||||
</analysis>
|
||||
|
||||
<summary>
|
||||
1. Primary Request and Intent:
|
||||
[Detailed description]
|
||||
|
||||
2. Key Technical Concepts:
|
||||
- [Concept 1]
|
||||
- [Concept 2]
|
||||
- [...]
|
||||
|
||||
3. Files and Code Sections:
|
||||
- [File Name 1]
|
||||
- [Summary of why this file is important]
|
||||
- [Summary of the changes made to this file, if any]
|
||||
- [Important Code Snippet]
|
||||
- [File Name 2]
|
||||
- [Important Code Snippet]
|
||||
- [...]
|
||||
|
||||
4. Errors and fixes:
|
||||
- [Detailed description of error 1]:
|
||||
- [How you fixed the error]
|
||||
- [User feedback on the error if any]
|
||||
- [...]
|
||||
|
||||
5. Problem Solving:
|
||||
[Description of solved problems and ongoing troubleshooting]
|
||||
|
||||
6. All user messages:
|
||||
- [Detailed non tool use user message]
|
||||
- [...]
|
||||
|
||||
7. Pending Tasks:
|
||||
- [Task 1]
|
||||
- [Task 2]
|
||||
- [...]
|
||||
|
||||
8. Current Work:
|
||||
[Precise description of current work]
|
||||
|
||||
9. Optional Next Step:
|
||||
[Optional Next step to take]
|
||||
|
||||
</summary>
|
||||
</example>
|
||||
|
||||
Please provide your summary based on the conversation so far, following this \
|
||||
structure and ensuring precision and thoroughness in your response.
|
||||
|
||||
There may be additional summarization instructions provided in the included \
|
||||
context. If so, remember to follow these instructions when creating the above \
|
||||
summary. Examples of instructions include:
|
||||
<example>
|
||||
## Compact Instructions
|
||||
When summarizing the conversation focus on typescript code changes and also \
|
||||
remember the mistakes you made and how you fixed them.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
# Summary instructions
|
||||
When you are using compact - please focus on test output and code changes. \
|
||||
Include file reads verbatim.
|
||||
</example>
|
||||
"""
|
||||
|
||||
_NO_TOOLS_TRAILER = (
|
||||
'\n\nREMINDER: Do NOT call any tools. Respond with plain text only — '
|
||||
'an <analysis> block followed by a <summary> block. '
|
||||
'Tool calls will be rejected and you will fail the task.'
|
||||
)
|
||||
|
||||
|
||||
def get_compact_prompt(custom_instructions: str | None = None) -> str:
|
||||
"""Build the full compact prompt, optionally appending user instructions."""
|
||||
prompt = _NO_TOOLS_PREAMBLE + _BASE_COMPACT_PROMPT
|
||||
if custom_instructions and custom_instructions.strip():
|
||||
prompt += f'\n\nAdditional Instructions:\n{custom_instructions}'
|
||||
prompt += _NO_TOOLS_TRAILER
|
||||
return prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_compact_summary(summary: str) -> str:
|
||||
"""Strip the ``<analysis>`` scratchpad and unwrap ``<summary>`` tags.
|
||||
|
||||
Mirrors the npm ``formatCompactSummary`` helper.
|
||||
"""
|
||||
formatted = re.sub(r'<analysis>[\s\S]*?</analysis>', '', summary)
|
||||
|
||||
match = re.search(r'<summary>([\s\S]*?)</summary>', formatted)
|
||||
if match:
|
||||
content = match.group(1).strip()
|
||||
formatted = re.sub(
|
||||
r'<summary>[\s\S]*?</summary>',
|
||||
f'Summary:\n{content}',
|
||||
formatted,
|
||||
)
|
||||
|
||||
# Collapse runs of blank lines.
|
||||
formatted = re.sub(r'\n\n+', '\n\n', formatted)
|
||||
return formatted.strip()
|
||||
|
||||
|
||||
def get_compact_user_summary_message(
|
||||
summary: str,
|
||||
*,
|
||||
suppress_follow_up: bool = False,
|
||||
transcript_path: str | None = None,
|
||||
) -> str:
|
||||
"""Build the user-facing summary that replaces compacted messages.
|
||||
|
||||
Mirrors the npm ``getCompactUserSummaryMessage`` helper.
|
||||
"""
|
||||
formatted = format_compact_summary(summary)
|
||||
|
||||
base = (
|
||||
'This session is being continued from a previous conversation that '
|
||||
'ran out of context. The summary below covers the earlier portion '
|
||||
f'of the conversation.\n\n{formatted}'
|
||||
)
|
||||
|
||||
if transcript_path:
|
||||
base += (
|
||||
'\n\nIf you need specific details from before compaction '
|
||||
'(like exact code snippets, error messages, or content you '
|
||||
'generated), read the full transcript at: '
|
||||
f'{transcript_path}'
|
||||
)
|
||||
|
||||
if suppress_follow_up:
|
||||
base += (
|
||||
'\nContinue the conversation from where it left off without '
|
||||
'asking the user any further questions. Resume directly — do '
|
||||
'not acknowledge the summary, do not recap what was happening, '
|
||||
'do not preface with "I\'ll continue" or similar. Pick up the '
|
||||
'last task as if the break never happened.'
|
||||
)
|
||||
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compaction result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CompactionResult:
|
||||
"""Outcome of a ``compact_conversation`` call."""
|
||||
|
||||
boundary_message: AgentMessage
|
||||
summary_messages: list[AgentMessage] = field(default_factory=list)
|
||||
messages_to_keep: list[AgentMessage] = field(default_factory=list)
|
||||
pre_compact_token_count: int = 0
|
||||
post_compact_token_count: int = 0
|
||||
summary_text: str = ''
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core compaction logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compact_conversation(
|
||||
agent: 'LocalCodingAgent',
|
||||
custom_instructions: str | None = None,
|
||||
) -> CompactionResult:
|
||||
"""Perform an LLM-backed 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. Parse ``<summary>`` from the response.
|
||||
5. Replace session messages with:
|
||||
boundary marker → summary user message → preserved tail.
|
||||
|
||||
Returns a :class:`CompactionResult` with diagnostics.
|
||||
"""
|
||||
session = agent.last_session
|
||||
if session is None or len(session.messages) == 0:
|
||||
return CompactionResult(
|
||||
boundary_message=_build_boundary('No session to compact.'),
|
||||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||||
)
|
||||
|
||||
# ---- Determine which messages to compact vs preserve ----
|
||||
# We keep the most recent ``preserve_count`` messages untouched.
|
||||
preserve_count = max(
|
||||
getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1
|
||||
)
|
||||
|
||||
# Identify the prefix count (system-injected messages that precede the
|
||||
# real conversation, e.g. a compaction-replay boundary).
|
||||
prefix_count = 0
|
||||
for msg in session.messages:
|
||||
if msg.metadata.get('kind') == 'compact_boundary':
|
||||
prefix_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
total = len(session.messages)
|
||||
tail_count = min(preserve_count, max(total - prefix_count, 0))
|
||||
compact_end = total - tail_count
|
||||
|
||||
if compact_end <= prefix_count:
|
||||
return CompactionResult(
|
||||
boundary_message=_build_boundary('Not enough messages after prefix.'),
|
||||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||||
)
|
||||
|
||||
candidates = session.messages[prefix_count:compact_end]
|
||||
preserved_tail = list(session.messages[compact_end:])
|
||||
|
||||
if not candidates:
|
||||
return CompactionResult(
|
||||
boundary_message=_build_boundary('Nothing to compact.'),
|
||||
error=ERROR_NOT_ENOUGH_MESSAGES,
|
||||
)
|
||||
|
||||
# ---- Estimate pre-compact token count ----
|
||||
model = agent.model_config.model
|
||||
pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
||||
|
||||
# ---- Build the compact request messages ----
|
||||
compact_prompt = get_compact_prompt(custom_instructions)
|
||||
|
||||
# We send the system prompt + candidate messages + the compact prompt as
|
||||
# a user message. The model returns the summary.
|
||||
api_messages: list[dict[str, Any]] = []
|
||||
|
||||
# System prompt (from session)
|
||||
for part in session.system_prompt_parts:
|
||||
if part.strip():
|
||||
api_messages.append({'role': 'system', 'content': part})
|
||||
|
||||
# Candidate messages (the ones to be summarised)
|
||||
for msg in candidates:
|
||||
api_messages.append(msg.to_openai_message())
|
||||
|
||||
# The compact prompt as the final user turn
|
||||
api_messages.append({'role': 'user', 'content': compact_prompt})
|
||||
|
||||
# ---- Call the model ----
|
||||
try:
|
||||
turn = agent.client.complete(api_messages, tools=[])
|
||||
except Exception as exc:
|
||||
return CompactionResult(
|
||||
boundary_message=_build_boundary(f'Compact API call failed: {exc}'),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
raw_summary = turn.content or ''
|
||||
if not raw_summary.strip():
|
||||
return CompactionResult(
|
||||
boundary_message=_build_boundary('Model returned empty summary.'),
|
||||
error=ERROR_INCOMPLETE_RESPONSE,
|
||||
)
|
||||
|
||||
# ---- Format the summary ----
|
||||
summary_text = format_compact_summary(raw_summary)
|
||||
user_summary_content = get_compact_user_summary_message(raw_summary)
|
||||
|
||||
# ---- Build post-compact messages ----
|
||||
boundary = _build_boundary(
|
||||
f'Earlier conversation ({len(candidates)} messages, ~{pre_tokens} tokens) '
|
||||
f'was compacted.',
|
||||
)
|
||||
|
||||
summary_msg = AgentMessage(
|
||||
role='user',
|
||||
content=user_summary_content,
|
||||
message_id='compact_summary',
|
||||
metadata={'kind': 'compact_summary', 'is_compact_summary': True},
|
||||
)
|
||||
|
||||
# Replace session messages in-place
|
||||
session.messages = (
|
||||
session.messages[:prefix_count]
|
||||
+ [boundary, summary_msg]
|
||||
+ preserved_tail
|
||||
)
|
||||
|
||||
# ---- Post-compact token estimate ----
|
||||
post_tokens = sum(estimate_tokens(m.content, model) for m in session.messages)
|
||||
|
||||
return CompactionResult(
|
||||
boundary_message=boundary,
|
||||
summary_messages=[summary_msg],
|
||||
messages_to_keep=preserved_tail,
|
||||
pre_compact_token_count=pre_tokens,
|
||||
post_compact_token_count=post_tokens,
|
||||
summary_text=summary_text,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_boundary(note: str) -> AgentMessage:
|
||||
"""Create a compact-boundary system message."""
|
||||
return AgentMessage(
|
||||
role='user',
|
||||
content=f'<system-reminder>\n{note}\n</system-reminder>',
|
||||
message_id='compact_boundary',
|
||||
metadata={'kind': 'compact_boundary'},
|
||||
)
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Prompt constants ported from npm src/constants/.
|
||||
|
||||
Covers: product metadata, API limits, tool limits, spinner verbs,
|
||||
turn-completion verbs, figures/symbols, XML tags, message constants,
|
||||
date utilities, system prompt section caching, output-style configs,
|
||||
and cyber-risk instruction.
|
||||
|
||||
npm sources:
|
||||
src/constants/product.ts
|
||||
src/constants/apiLimits.ts
|
||||
src/constants/toolLimits.ts
|
||||
src/constants/spinnerVerbs.ts
|
||||
src/constants/turnCompletionVerbs.ts
|
||||
src/constants/figures.ts
|
||||
src/constants/xml.ts
|
||||
src/constants/messages.ts
|
||||
src/constants/common.ts
|
||||
src/constants/systemPromptSections.ts
|
||||
src/constants/outputStyles.ts
|
||||
src/constants/cyberRiskInstruction.ts
|
||||
src/constants/system.ts
|
||||
src/constants/prompts.ts (selected sections)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product metadata (product.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRODUCT_URL = "https://claude.com/claude-code"
|
||||
CLAUDE_AI_BASE_URL = "https://claude.ai"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt prefixes (system.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_SYSPROMPT_PREFIX = (
|
||||
"You are Claude Code, Anthropic's official CLI for Claude."
|
||||
)
|
||||
AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX = (
|
||||
"You are Claude Code, Anthropic's official CLI for Claude, "
|
||||
"running within the Claude Agent SDK."
|
||||
)
|
||||
AGENT_SDK_PREFIX = (
|
||||
"You are a Claude agent, built on Anthropic's Claude Agent SDK."
|
||||
)
|
||||
CLI_SYSPROMPT_PREFIXES = frozenset(
|
||||
{
|
||||
DEFAULT_SYSPROMPT_PREFIX,
|
||||
AGENT_SDK_CLAUDE_CODE_PRESET_PREFIX,
|
||||
AGENT_SDK_PREFIX,
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cyber-risk instruction (cyberRiskInstruction.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CYBER_RISK_INSTRUCTION = (
|
||||
"IMPORTANT: Assist with authorized security testing, defensive security, "
|
||||
"CTF challenges, and educational contexts. Refuse requests for destructive "
|
||||
"techniques, DoS attacks, mass targeting, supply chain compromise, or "
|
||||
"detection evasion for malicious purposes. Dual-use security tools (C2 "
|
||||
"frameworks, credential testing, exploit development) require clear "
|
||||
"authorization context: pentesting engagements, CTF competitions, security "
|
||||
"research, or defensive use cases."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API limits (apiLimits.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Image limits
|
||||
API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024 # 5 MB base64
|
||||
IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) // 4 # ~3.75 MB
|
||||
IMAGE_MAX_WIDTH = 2000
|
||||
IMAGE_MAX_HEIGHT = 2000
|
||||
|
||||
# PDF limits
|
||||
PDF_TARGET_RAW_SIZE = 20 * 1024 * 1024 # 20 MB
|
||||
API_PDF_MAX_PAGES = 100
|
||||
PDF_EXTRACT_SIZE_THRESHOLD = 3 * 1024 * 1024 # 3 MB
|
||||
PDF_MAX_EXTRACT_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
PDF_MAX_PAGES_PER_READ = 20
|
||||
PDF_AT_MENTION_INLINE_THRESHOLD = 10
|
||||
|
||||
# Media limits
|
||||
API_MAX_MEDIA_PER_REQUEST = 100
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool limits (toolLimits.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000
|
||||
MAX_TOOL_RESULT_TOKENS = 100_000
|
||||
BYTES_PER_TOKEN = 4
|
||||
MAX_TOOL_RESULT_BYTES = MAX_TOOL_RESULT_TOKENS * BYTES_PER_TOKEN # 400 KB
|
||||
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000
|
||||
TOOL_SUMMARY_MAX_LENGTH = 50
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spinner verbs (spinnerVerbs.ts) — 204 whimsical gerunds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SPINNER_VERBS: tuple[str, ...] = (
|
||||
"Accomplishing",
|
||||
"Actioning",
|
||||
"Actualizing",
|
||||
"Architecting",
|
||||
"Baking",
|
||||
"Beaming",
|
||||
"Beboppin'",
|
||||
"Befuddling",
|
||||
"Billowing",
|
||||
"Blanching",
|
||||
"Bloviating",
|
||||
"Boogieing",
|
||||
"Boondoggling",
|
||||
"Booping",
|
||||
"Bootstrapping",
|
||||
"Brewing",
|
||||
"Bunning",
|
||||
"Burrowing",
|
||||
"Calculating",
|
||||
"Canoodling",
|
||||
"Caramelizing",
|
||||
"Cascading",
|
||||
"Catapulting",
|
||||
"Cerebrating",
|
||||
"Channeling",
|
||||
"Channelling",
|
||||
"Choreographing",
|
||||
"Churning",
|
||||
"Clauding",
|
||||
"Coalescing",
|
||||
"Cogitating",
|
||||
"Combobulating",
|
||||
"Composing",
|
||||
"Computing",
|
||||
"Concocting",
|
||||
"Considering",
|
||||
"Contemplating",
|
||||
"Cooking",
|
||||
"Crafting",
|
||||
"Creating",
|
||||
"Crunching",
|
||||
"Crystallizing",
|
||||
"Cultivating",
|
||||
"Deciphering",
|
||||
"Deliberating",
|
||||
"Determining",
|
||||
"Dilly-dallying",
|
||||
"Discombobulating",
|
||||
"Doing",
|
||||
"Doodling",
|
||||
"Drizzling",
|
||||
"Ebbing",
|
||||
"Effecting",
|
||||
"Elucidating",
|
||||
"Embellishing",
|
||||
"Enchanting",
|
||||
"Envisioning",
|
||||
"Evaporating",
|
||||
"Fermenting",
|
||||
"Fiddle-faddling",
|
||||
"Finagling",
|
||||
"Flambéing",
|
||||
"Flibbertigibbeting",
|
||||
"Flowing",
|
||||
"Flummoxing",
|
||||
"Fluttering",
|
||||
"Forging",
|
||||
"Forming",
|
||||
"Frolicking",
|
||||
"Frosting",
|
||||
"Gallivanting",
|
||||
"Galloping",
|
||||
"Garnishing",
|
||||
"Generating",
|
||||
"Gesticulating",
|
||||
"Germinating",
|
||||
"Gitifying",
|
||||
"Grooving",
|
||||
"Gusting",
|
||||
"Harmonizing",
|
||||
"Hashing",
|
||||
"Hatching",
|
||||
"Herding",
|
||||
"Honking",
|
||||
"Hullaballooing",
|
||||
"Hyperspacing",
|
||||
"Ideating",
|
||||
"Imagining",
|
||||
"Improvising",
|
||||
"Incubating",
|
||||
"Inferring",
|
||||
"Infusing",
|
||||
"Ionizing",
|
||||
"Jitterbugging",
|
||||
"Julienning",
|
||||
"Kneading",
|
||||
"Leavening",
|
||||
"Levitating",
|
||||
"Lollygagging",
|
||||
"Manifesting",
|
||||
"Marinating",
|
||||
"Meandering",
|
||||
"Metamorphosing",
|
||||
"Misting",
|
||||
"Moonwalking",
|
||||
"Moseying",
|
||||
"Mulling",
|
||||
"Mustering",
|
||||
"Musing",
|
||||
"Nebulizing",
|
||||
"Nesting",
|
||||
"Newspapering",
|
||||
"Noodling",
|
||||
"Nucleating",
|
||||
"Orbiting",
|
||||
"Orchestrating",
|
||||
"Osmosing",
|
||||
"Perambulating",
|
||||
"Percolating",
|
||||
"Perusing",
|
||||
"Philosophising",
|
||||
"Photosynthesizing",
|
||||
"Pollinating",
|
||||
"Pondering",
|
||||
"Pontificating",
|
||||
"Pouncing",
|
||||
"Precipitating",
|
||||
"Prestidigitating",
|
||||
"Processing",
|
||||
"Proofing",
|
||||
"Propagating",
|
||||
"Puttering",
|
||||
"Puzzling",
|
||||
"Quantumizing",
|
||||
"Razzle-dazzling",
|
||||
"Razzmatazzing",
|
||||
"Recombobulating",
|
||||
"Reticulating",
|
||||
"Roosting",
|
||||
"Ruminating",
|
||||
"Sautéing",
|
||||
"Scampering",
|
||||
"Schlepping",
|
||||
"Scurrying",
|
||||
"Seasoning",
|
||||
"Shenaniganing",
|
||||
"Shimmying",
|
||||
"Simmering",
|
||||
"Skedaddling",
|
||||
"Sketching",
|
||||
"Slithering",
|
||||
"Smooshing",
|
||||
"Sock-hopping",
|
||||
"Spelunking",
|
||||
"Spinning",
|
||||
"Sprouting",
|
||||
"Stewing",
|
||||
"Sublimating",
|
||||
"Swirling",
|
||||
"Swooping",
|
||||
"Symbioting",
|
||||
"Synthesizing",
|
||||
"Tempering",
|
||||
"Thinking",
|
||||
"Thundering",
|
||||
"Tinkering",
|
||||
"Tomfoolering",
|
||||
"Topsy-turvying",
|
||||
"Transfiguring",
|
||||
"Transmuting",
|
||||
"Twisting",
|
||||
"Undulating",
|
||||
"Unfurling",
|
||||
"Unravelling",
|
||||
"Vibing",
|
||||
"Waddling",
|
||||
"Wandering",
|
||||
"Warping",
|
||||
"Whatchamacalliting",
|
||||
"Whirlpooling",
|
||||
"Whirring",
|
||||
"Whisking",
|
||||
"Wibbling",
|
||||
"Working",
|
||||
"Wrangling",
|
||||
"Zesting",
|
||||
"Zigzagging",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Turn-completion verbs (turnCompletionVerbs.ts) — 8 past-tense verbs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TURN_COMPLETION_VERBS: tuple[str, ...] = (
|
||||
"Baked",
|
||||
"Brewed",
|
||||
"Churned",
|
||||
"Cogitated",
|
||||
"Cooked",
|
||||
"Crunched",
|
||||
"Sautéed",
|
||||
"Worked",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Figures / UI symbols (figures.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BLACK_CIRCLE = "\u23fa" if platform.system() == "Darwin" else "\u25cf" # ⏺ / ●
|
||||
BULLET_OPERATOR = "\u2219" # ∙
|
||||
TEARDROP_ASTERISK = "\u273b" # ✻
|
||||
UP_ARROW = "\u2191" # ↑
|
||||
DOWN_ARROW = "\u2193" # ↓
|
||||
LIGHTNING_BOLT = "\u21af" # ↯
|
||||
EFFORT_LOW = "\u25cb" # ○
|
||||
EFFORT_MEDIUM = "\u25d0" # ◐
|
||||
EFFORT_HIGH = "\u25cf" # ●
|
||||
EFFORT_MAX = "\u25c9" # ◉
|
||||
|
||||
PLAY_ICON = "\u25b6" # ▶
|
||||
PAUSE_ICON = "\u23f8" # ⏸
|
||||
|
||||
REFRESH_ARROW = "\u21bb" # ↻
|
||||
CHANNEL_ARROW = "\u2190" # ←
|
||||
INJECTED_ARROW = "\u2192" # →
|
||||
FORK_GLYPH = "\u2442" # ⑂
|
||||
|
||||
DIAMOND_OPEN = "\u25c7" # ◇
|
||||
DIAMOND_FILLED = "\u25c6" # ◆
|
||||
REFERENCE_MARK = "\u203b" # ※
|
||||
|
||||
FLAG_ICON = "\u2691" # ⚑
|
||||
BLOCKQUOTE_BAR = "\u258e" # ▎
|
||||
HEAVY_HORIZONTAL = "\u2501" # ━
|
||||
|
||||
BRIDGE_SPINNER_FRAMES: tuple[str, ...] = (
|
||||
"\u00b7|\u00b7",
|
||||
"\u00b7/\u00b7",
|
||||
"\u00b7\u2014\u00b7",
|
||||
"\u00b7\\\u00b7",
|
||||
)
|
||||
BRIDGE_READY_INDICATOR = "\u00b7\u2714\ufe0e\u00b7"
|
||||
BRIDGE_FAILED_INDICATOR = "\u00d7"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XML tag constants (xml.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMMAND_NAME_TAG = "command-name"
|
||||
COMMAND_MESSAGE_TAG = "command-message"
|
||||
COMMAND_ARGS_TAG = "command-args"
|
||||
|
||||
BASH_INPUT_TAG = "bash-input"
|
||||
BASH_STDOUT_TAG = "bash-stdout"
|
||||
BASH_STDERR_TAG = "bash-stderr"
|
||||
LOCAL_COMMAND_STDOUT_TAG = "local-command-stdout"
|
||||
LOCAL_COMMAND_STDERR_TAG = "local-command-stderr"
|
||||
LOCAL_COMMAND_CAVEAT_TAG = "local-command-caveat"
|
||||
|
||||
TERMINAL_OUTPUT_TAGS: tuple[str, ...] = (
|
||||
BASH_INPUT_TAG,
|
||||
BASH_STDOUT_TAG,
|
||||
BASH_STDERR_TAG,
|
||||
LOCAL_COMMAND_STDOUT_TAG,
|
||||
LOCAL_COMMAND_STDERR_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
)
|
||||
|
||||
TICK_TAG = "tick"
|
||||
|
||||
TASK_NOTIFICATION_TAG = "task-notification"
|
||||
TASK_ID_TAG = "task-id"
|
||||
TOOL_USE_ID_TAG = "tool-use-id"
|
||||
TASK_TYPE_TAG = "task-type"
|
||||
OUTPUT_FILE_TAG = "output-file"
|
||||
STATUS_TAG = "status"
|
||||
SUMMARY_TAG = "summary"
|
||||
REASON_TAG = "reason"
|
||||
WORKTREE_TAG = "worktree"
|
||||
WORKTREE_PATH_TAG = "worktreePath"
|
||||
WORKTREE_BRANCH_TAG = "worktreeBranch"
|
||||
|
||||
ULTRAPLAN_TAG = "ultraplan"
|
||||
REMOTE_REVIEW_TAG = "remote-review"
|
||||
REMOTE_REVIEW_PROGRESS_TAG = "remote-review-progress"
|
||||
TEAMMATE_MESSAGE_TAG = "teammate-message"
|
||||
CHANNEL_MESSAGE_TAG = "channel-message"
|
||||
CHANNEL_TAG = "channel"
|
||||
CROSS_SESSION_MESSAGE_TAG = "cross-session-message"
|
||||
|
||||
FORK_BOILERPLATE_TAG = "fork-boilerplate"
|
||||
FORK_DIRECTIVE_PREFIX = "Your directive: "
|
||||
|
||||
COMMON_HELP_ARGS: tuple[str, ...] = ("help", "-h", "--help")
|
||||
COMMON_INFO_ARGS: tuple[str, ...] = (
|
||||
"list",
|
||||
"show",
|
||||
"display",
|
||||
"current",
|
||||
"view",
|
||||
"get",
|
||||
"check",
|
||||
"describe",
|
||||
"print",
|
||||
"version",
|
||||
"about",
|
||||
"status",
|
||||
"?",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message constants (messages.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NO_CONTENT_MESSAGE = "(no content)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date utilities (common.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_local_iso_date() -> str:
|
||||
"""Return the local date in YYYY-MM-DD format.
|
||||
|
||||
Respects ``CLAUDE_CODE_OVERRIDE_DATE`` env var.
|
||||
"""
|
||||
override = os.environ.get("CLAUDE_CODE_OVERRIDE_DATE")
|
||||
if override:
|
||||
return override
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
_session_start_date_lock = threading.Lock()
|
||||
_session_start_date: str | None = None
|
||||
|
||||
|
||||
def get_session_start_date() -> str:
|
||||
"""Memoised local date — captured once per session."""
|
||||
global _session_start_date
|
||||
if _session_start_date is not None:
|
||||
return _session_start_date
|
||||
with _session_start_date_lock:
|
||||
if _session_start_date is None:
|
||||
_session_start_date = get_local_iso_date()
|
||||
return _session_start_date
|
||||
|
||||
|
||||
def reset_session_start_date() -> None:
|
||||
"""Reset the memoised date (for tests)."""
|
||||
global _session_start_date
|
||||
_session_start_date = None
|
||||
|
||||
|
||||
def get_local_month_year() -> str:
|
||||
"""Return ``"Month YYYY"`` (e.g. ``"February 2026"``)."""
|
||||
override = os.environ.get("CLAUDE_CODE_OVERRIDE_DATE")
|
||||
if override:
|
||||
d = datetime.fromisoformat(override)
|
||||
else:
|
||||
d = datetime.now()
|
||||
return d.strftime("%B %Y")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt section caching (systemPromptSections.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ComputeFn = Callable[[], str | None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemPromptSection:
|
||||
"""A named section of the system prompt with lazy compute."""
|
||||
name: str
|
||||
compute: ComputeFn
|
||||
cache_break: bool = False
|
||||
|
||||
|
||||
def system_prompt_section(name: str, compute: ComputeFn) -> SystemPromptSection:
|
||||
"""Create a memoised prompt section (cached until /clear or /compact)."""
|
||||
return SystemPromptSection(name=name, compute=compute, cache_break=False)
|
||||
|
||||
|
||||
def dangerous_uncached_system_prompt_section(
|
||||
name: str,
|
||||
compute: ComputeFn,
|
||||
_reason: str = "",
|
||||
) -> SystemPromptSection:
|
||||
"""Prompt section that recomputes every turn (breaks prompt cache)."""
|
||||
return SystemPromptSection(name=name, compute=compute, cache_break=True)
|
||||
|
||||
|
||||
_section_cache: dict[str, str | None] = {}
|
||||
|
||||
|
||||
def resolve_system_prompt_sections(
|
||||
sections: list[SystemPromptSection],
|
||||
) -> list[str | None]:
|
||||
"""Resolve sections, caching non-volatile ones."""
|
||||
results: list[str | None] = []
|
||||
for section in sections:
|
||||
if not section.cache_break and section.name in _section_cache:
|
||||
results.append(_section_cache[section.name])
|
||||
continue
|
||||
value = section.compute()
|
||||
_section_cache[section.name] = value
|
||||
results.append(value)
|
||||
return results
|
||||
|
||||
|
||||
def clear_system_prompt_sections() -> None:
|
||||
"""Clear cached prompt sections (called on /clear and /compact)."""
|
||||
_section_cache.clear()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output style configuration (outputStyles.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_OUTPUT_STYLE_NAME = "default"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputStyleConfig:
|
||||
name: str
|
||||
description: str
|
||||
prompt: str
|
||||
source: str = "built-in"
|
||||
keep_coding_instructions: bool = True
|
||||
force_for_plugin: bool = False
|
||||
|
||||
|
||||
# Built-in output styles matching npm
|
||||
OUTPUT_STYLE_CONFIGS: dict[str, OutputStyleConfig | None] = {
|
||||
DEFAULT_OUTPUT_STYLE_NAME: None,
|
||||
"Explanatory": OutputStyleConfig(
|
||||
name="Explanatory",
|
||||
description="Claude explains its implementation choices and codebase patterns",
|
||||
prompt=(
|
||||
"You are an interactive CLI tool that helps users with software "
|
||||
"engineering tasks. In addition to software engineering tasks, you "
|
||||
"should provide educational insights about the codebase along the way.\n\n"
|
||||
"You should be clear and educational, providing helpful explanations "
|
||||
"while remaining focused on the task. Balance educational content "
|
||||
"with task completion."
|
||||
),
|
||||
),
|
||||
"Learning": OutputStyleConfig(
|
||||
name="Learning",
|
||||
description="Claude pauses and asks you to write small pieces of code for hands-on practice",
|
||||
prompt=(
|
||||
"You are an interactive CLI tool that helps users with software "
|
||||
"engineering tasks. In addition to software engineering tasks, you "
|
||||
"should help users learn more about the codebase through hands-on "
|
||||
"practice and educational insights.\n\n"
|
||||
"You should be collaborative and encouraging. Balance task completion "
|
||||
"with learning by requesting user input for meaningful design "
|
||||
"decisions while handling routine implementation yourself."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knowledge cutoff (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRONTIER_MODEL_NAME = "Claude Opus 4.6"
|
||||
|
||||
_KNOWLEDGE_CUTOFFS: dict[str, str] = {
|
||||
"claude-sonnet-4-6": "August 2025",
|
||||
"claude-opus-4-6": "May 2025",
|
||||
"claude-opus-4-5": "May 2025",
|
||||
"claude-haiku-4": "February 2025",
|
||||
"claude-opus-4": "January 2025",
|
||||
"claude-sonnet-4": "January 2025",
|
||||
}
|
||||
|
||||
|
||||
def get_knowledge_cutoff(model_id: str) -> str | None:
|
||||
"""Return knowledge cutoff date for a model, or None."""
|
||||
canonical = model_id.lower()
|
||||
for pattern, cutoff in _KNOWLEDGE_CUTOFFS.items():
|
||||
if pattern in canonical:
|
||||
return cutoff
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model family IDs (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLAUDE_MODEL_IDS = {
|
||||
"opus": "claude-opus-4-6",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hooks section (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOOKS_SECTION = (
|
||||
"Users may configure 'hooks', shell commands that execute in response to "
|
||||
"events like tool calls, in settings. Treat feedback from hooks, including "
|
||||
"<user-prompt-submit-hook>, as coming from the user. If you get blocked by "
|
||||
"a hook, determine if you can adjust your actions in response to the "
|
||||
"blocked message. If not, ask the user to check their hooks configuration."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System reminders section (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_REMINDERS_SECTION = (
|
||||
"- Tool results and user messages may include <system-reminder> tags. "
|
||||
"<system-reminder> tags contain useful information and reminders. They are "
|
||||
"automatically added by the system, and bear no direct relation to the "
|
||||
"specific tool results or user messages in which they appear.\n"
|
||||
"- The conversation has unlimited context through automatic summarization."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summarize tool results (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUMMARIZE_TOOL_RESULTS_SECTION = (
|
||||
"When working with tool results, write down any important information you "
|
||||
"might need later in your response, as the original tool result may be "
|
||||
"cleared later."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default agent prompt (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_AGENT_PROMPT = (
|
||||
"You are an agent for Claude Code, Anthropic's official CLI for Claude. "
|
||||
"Given the user's message, you should use the tools available to complete "
|
||||
"the task. Complete the task fully\u2014don't gold-plate, but don't leave "
|
||||
"it half-done. When you complete the task, respond with a concise report "
|
||||
"covering what was done and any key findings \u2014 the caller will relay "
|
||||
"this to the user, so it only needs the essentials."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error IDs (errorIds.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
E_TOOL_USE_SUMMARY_GENERATION_FAILED = 344
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic boundary marker (prompts.ts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience helpers for use in prompt building
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_language_section(language_preference: str | None) -> str | None:
|
||||
"""Return the language preference prompt section, or None."""
|
||||
if not language_preference:
|
||||
return None
|
||||
return (
|
||||
f"# Language\n"
|
||||
f"Always respond in {language_preference}. Use {language_preference} "
|
||||
f"for all explanations, comments, and communications with the user. "
|
||||
f"Technical terms and code identifiers should remain in their original form."
|
||||
)
|
||||
|
||||
|
||||
def get_output_style_section(config: OutputStyleConfig | None) -> str | None:
|
||||
"""Return the output-style prompt section, or None."""
|
||||
if config is None:
|
||||
return None
|
||||
return f"# Output Style: {config.name}\n{config.prompt}"
|
||||
|
||||
|
||||
def get_scratchpad_instructions(scratchpad_dir: str | None) -> str | None:
|
||||
"""Return scratchpad instructions, or None if no scratchpad is configured."""
|
||||
if not scratchpad_dir:
|
||||
return None
|
||||
return (
|
||||
f"# Scratchpad Directory\n\n"
|
||||
f"IMPORTANT: Always use this scratchpad directory for temporary files "
|
||||
f"instead of `/tmp` or other system temp directories:\n"
|
||||
f"`{scratchpad_dir}`\n\n"
|
||||
f"Use this directory for ALL temporary file needs:\n"
|
||||
f"- Storing intermediate results or data during multi-step tasks\n"
|
||||
f"- Writing temporary scripts or configuration files\n"
|
||||
f"- Saving outputs that don't belong in the user's project\n"
|
||||
f"- Creating working files during analysis or processing\n"
|
||||
f"- Any file that would otherwise go to `/tmp`\n\n"
|
||||
f"Only use `/tmp` if the user explicitly requests it.\n\n"
|
||||
f"The scratchpad directory is session-specific, isolated from the "
|
||||
f"user's project, and can be used freely without permission prompts."
|
||||
)
|
||||
Reference in New Issue
Block a user