Files
MyAiConfiguration/scripts/benchmarks/context.py
T

157 lines
6.7 KiB
Python

"""Report the context the installed clients actually load, as the clients report it.
Complements the byte-based prompt budget gate: Claude's own `/context` and `/skill-doctor`
output and its init event, and Codex's `debug prompt-input`. No model request is made.
With --check, fail when an installed skill is missing from the client listing or is listed
without description context (for example because the client's skill budget dropped it).
"""
import argparse
import json
import os
from pathlib import Path
import queue
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import tomllib
TIMEOUT = 180
def kill_tree(process):
if os.name == 'nt':
subprocess.run(['taskkill', '/T', '/F', '/PID', str(process.pid)], capture_output=True, timeout=30)
else:
process.kill()
process.wait(timeout=30)
def clean_env():
return {key: value for key, value in os.environ.items() if not key.startswith(('BUN_INSPECT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'))}
def skill_names(root):
names = set()
for path in root.rglob('SKILL.md') if root.is_dir() else []:
match = re.search(r'(?m)^name:\s*"?([^"\r\n]+?)"?\s*$', path.read_text(encoding='utf-8-sig', errors='replace'))
if match:
names.add(match.group(1))
return names
def tokens(text):
# Claude prints "< 20" for small nonzero counts; count it as its upper bound.
text = text.strip().lstrip('~<').strip()
if text in ('', '-'):
return 0
return round(float(text[:-1]) * 1000) if text.endswith('k') else round(float(text))
def table_rows(markdown, heading):
section = re.search(rf'(?ms)^### {re.escape(heading)}\s*$(.*?)(?=^### |\Z)', markdown)
rows = []
for line in (section.group(1) if section else '').splitlines():
cells = [cell.strip() for cell in line.strip().strip('|').split('|')]
if line.startswith('|') and len(cells) >= 2 and not set(cells[0]) <= set('-') and cells[0] not in ('Category', 'Skill', 'Agent Type', 'Type', 'Tool'):
rows.append(cells)
return rows
def claude_print(claude, work, prompt, extra=()):
result = subprocess.run([claude, '-p', prompt, '--output-format', 'json', *extra], cwd=work, env=clean_env(),
capture_output=True, text=True, encoding='utf-8', timeout=TIMEOUT)
return json.loads(result.stdout).get('result', '')
def claude_init(claude, work, extra=()):
process = subprocess.Popen([claude, '-p', 'context probe', '--output-format', 'stream-json', '--verbose', *extra], cwd=work,
env=clean_env(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, encoding='utf-8')
lines = queue.Queue()
threading.Thread(target=lambda: [lines.put(line) for line in process.stdout], daemon=True).start()
try:
while True:
event = json.loads(lines.get(timeout=TIMEOUT))
if event.get('subtype') == 'init':
return event
finally:
kill_tree(process)
def measure_claude(home, work):
claude = shutil.which('claude')
if not claude:
return {'available': False}
context = claude_print(claude, work, '/context')
doctor = claude_print(claude, work, '/skill-doctor')
init = claude_init(claude, work)
listed = {row[0]: tokens(row[2]) for row in table_rows(context, 'Skills') if len(row) >= 3}
doctor_context = {}
for line in doctor.splitlines():
cells = line.split()
if len(cells) >= 3 and re.fullmatch(r'~?\d+(\.\d+)?k?|-', cells[2]):
doctor_context[cells[0]] = tokens(cells[2])
installed = skill_names(home / '.claude/skills')
return {
'available': True,
'categories': {row[0]: tokens(row[1]) for row in table_rows(context, 'Estimated usage by category')},
'plugins': sorted(plugin.get('name', str(plugin)) if isinstance(plugin, dict) else str(plugin) for plugin in init.get('plugins', [])),
'agents': len(init.get('agents', [])),
'mcp_servers': len(init.get('mcp_servers', [])),
'skills_listed': len(listed),
'installed_skills_missing': sorted(installed - set(listed)),
'skills_without_context': sorted(name for name, value in {**listed, **doctor_context}.items() if value == 0),
}
def measure_codex(home, work):
codex = shutil.which('codex')
if not codex:
return {'available': False}
result = subprocess.run([codex, 'debug', 'prompt-input', 'context probe'], cwd=work, env=clean_env(),
capture_output=True, text=True, encoding='utf-8', timeout=TIMEOUT)
prompt = result.stdout
listed = set(re.findall(r'(?m)^- ([^:\n]+?): .+?\(file: ', '\n'.join(
part.get('text', '') for item in json.loads(prompt or '[]') for part in item.get('content', []) if isinstance(part, dict))))
config_path = home / '.codex/config.toml'
config = tomllib.loads(config_path.read_text(encoding='utf-8-sig')) if config_path.is_file() else {}
installed = skill_names(home / '.agents/skills')
return {
'available': True,
'prompt_input_tokens_estimate': len(prompt.encode('utf-8')) // 4,
'plugins': sorted(name for name, value in config.get('plugins', {}).items() if isinstance(value, dict) and value.get('enabled', True)),
'skills_listed': len(listed),
'installed_skills_missing': sorted(installed - listed),
}
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--home', type=Path, default=Path.home())
parser.add_argument('--json', action='store_true')
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='ai-config-context-', ignore_cleanup_errors=True) as directory:
subprocess.run(['git', 'init', '-q'], cwd=directory, check=True, timeout=60)
report = {'claude': measure_claude(args.home, directory), 'codex': measure_codex(args.home, directory)}
if args.json:
print(json.dumps(report, indent=2))
else:
for client, data in report.items():
if not data['available']:
print(f'{client}: CLI unavailable')
continue
print(f'{client}: {data["skills_listed"]} skills listed, plugins: {", ".join(data["plugins"]) or "none"}')
for key, value in data.items():
if key not in ('available', 'plugins', 'skills_listed'):
print(f' {key}: {value}')
problems = [name for data in report.values() if data['available'] for name in data['installed_skills_missing'] + data.get('skills_without_context', [])]
return 1 if args.check and problems else 0
if __name__ == '__main__':
sys.exit(main())