mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-08 08:53:43 +08:00
Fixes codebase://architecture returning only 10 results all from graph-ui by wiring hardcoded limits through the config system and raising defaults to 25. Key changes: - mcp.c: add key_functions_count config (default 25); wire into build_key_functions_sql (was hardcoded LIMIT 10 at line 4317) and build_resource_architecture call site - mcp.c: add arch_hotspot_limit config (default 25); wire into classic get_architecture tool handler - store.c/store.h: raise CBM_ARCH_HOTSPOT_DEFAULT_LIMIT 10->25; add hotspot_limit param to cbm_store_get_architecture - store.c/store.h: add sort_by=calls (ORDER BY calls_in+calls_out DESC) and sort_by=linkrank (ORDER BY linkrank_in DESC) dispatch cases; add degree_mode config (weighted|unweighted|calls_only) for min_degree/max_degree filter column selection - watcher.c/watcher.h: add poll_base_ms/poll_max_ms to struct cbm_watcher; change cbm_watcher_run and cbm_watcher_poll_interval_ms signatures to accept base_ms/max_ms params (0=defaults); wire watcher_poll_base_ms and watcher_poll_max_ms config keys through main.c - cli.h: extend cbm_config_entry_t with range and guidance fields (5->7) - cli.c: replace entire CBM_CONFIG_REGISTRY with 7-field entries for all 32 config keys with broadest feasible ranges and actionable guidance strings; update config list/get/help display to print [range] + guidance per entry - scripts/autotune.py: new standalone Python 3.9+ script that sends JSON-RPC directly to the binary via stdin/stdout, tries 7 experiments, scores against expected top-10 ground truth for 3 repos, resets config on exit - tests: update all callers of cbm_store_get_architecture (pass 0 for hotspot_limit) and cbm_watcher_poll_interval_ms (pass 0,0 for defaults) All 2238 tests pass. Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
479 lines
19 KiB
Python
479 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
autotune.py — Auto-tune codebase-memory-mcp ranking parameters.
|
|
|
|
Usage:
|
|
python3 scripts/autotune.py [--binary PATH] [--timeout SECS] [--clone]
|
|
[--repo-url NAME=URL ...]
|
|
|
|
Sends JSON-RPC directly to the binary via stdin/stdout (no MCP client library).
|
|
For each experiment: resets config to defaults, applies overrides, queries
|
|
codebase://architecture for each repo, scores results against the expected top-10
|
|
ground truth, and reports the best-scoring configuration.
|
|
|
|
Config changes are GLOBAL (stored in the binary's SQLite config DB). The script
|
|
resets all tunable keys to defaults on exit — including after errors — via atexit.
|
|
|
|
Repo discovery order (for each repo):
|
|
1. candidate_paths checked in order (primary system paths first)
|
|
2. If --clone and a URL is known (via --repo-url or clone_url), clone to the
|
|
last candidate path (adjacent to this script file)
|
|
3. If no URL available, print a hint and return None
|
|
|
|
Examples:
|
|
python3 scripts/autotune.py
|
|
python3 scripts/autotune.py --timeout 120 # for first-time indexing
|
|
python3 scripts/autotune.py --clone --repo-url rtk=https://github.com/user/rtk
|
|
python3 scripts/autotune.py --binary /usr/local/bin/codebase-memory-mcp
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import atexit
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Directory containing this script — used as the fallback clone target root.
|
|
_SCRIPT_DIR = Path(__file__).parent
|
|
|
|
|
|
# ── Repo definitions ──────────────────────────────────────────────────────────
|
|
# Each Repo lists: candidate_paths to check in order, expected top-10 ground
|
|
# truth names, and an optional clone_url (may be None for private repos).
|
|
# Users can supply clone URLs at runtime with --repo-url name=https://...
|
|
|
|
@dataclass
|
|
class Repo:
|
|
name: str
|
|
expected: list[str]
|
|
candidate_paths: list[Path]
|
|
clone_url: str | None = None # None = private / URL unknown
|
|
|
|
|
|
REPOS: list[Repo] = [
|
|
Repo(
|
|
name="codebase-memory-mcp",
|
|
expected=[
|
|
"cbm_arena_alloc", # in-degree 21, core allocator
|
|
"cbm_store_close", # in-degree 19
|
|
"cbm_store_upsert_node", # in-degree 18
|
|
"cbm_gbuf_insert_edge", # in-degree 18
|
|
"cbm_node_text", # in-degree 18
|
|
"cbm_arena_strdup", # in-degree 18
|
|
"cbm_pagerank_compute_with_config", # PageRank entry point
|
|
"cbm_mcp_server_handle", # MCP entry point
|
|
"cbm_pipeline_check_cancel", # pipeline control
|
|
"build_key_functions_sql", # architecture SQL builder
|
|
],
|
|
candidate_paths=[
|
|
Path.home() / ".claude/codebase-memory-mcp", # primary (developer)
|
|
Path.home() / "codebase-memory-mcp", # alternate home location
|
|
_SCRIPT_DIR / "codebase-memory-mcp", # adjacent to script (clone target)
|
|
],
|
|
clone_url=None, # supply via --repo-url codebase-memory-mcp=https://...
|
|
),
|
|
Repo(
|
|
name="autorun",
|
|
expected=[
|
|
"session_state", # 375 callers — hot path
|
|
"check_blocked_commands", # 170 callers — command engine
|
|
"command_matches_pattern", # 145 callers
|
|
"_not_in_pipe", # 106 callers
|
|
"get_tmux_utilities", # 96 callers
|
|
"is_premature_stop", # 64 callers
|
|
"normalize_hook_payload", # 60 callers
|
|
"validate_hook_response", # core hook
|
|
"SessionStateManager", # key class
|
|
"AutorunApp", # main class
|
|
],
|
|
candidate_paths=[
|
|
Path.home() / ".claude/autorun", # primary (developer)
|
|
Path.home() / "autorun", # alternate
|
|
_SCRIPT_DIR / "autorun", # adjacent to script (clone target)
|
|
],
|
|
clone_url=None, # supply via --repo-url autorun=https://...
|
|
),
|
|
Repo(
|
|
name="rtk",
|
|
expected=[
|
|
"tokenize", # 115 callers — central lexer
|
|
"resolved_command", # 77 callers
|
|
"status", # 68 callers (hook_check.rs)
|
|
"strip_ansi", # high combined degree
|
|
"check_for_hook", # main hook dispatch
|
|
"check_for_hook_inner", # hook logic
|
|
"try_route_native_command", # routing
|
|
"auto_detect_filter", # pipe detection
|
|
"estimate_tokens", # token tracking
|
|
"make_filters", # filter config
|
|
# EXCLUDED: args() — test helper with 300 callers, not production code
|
|
],
|
|
candidate_paths=[
|
|
Path.home() / "source/rtk", # primary (developer)
|
|
Path.home() / "rtk", # alternate
|
|
_SCRIPT_DIR / "rtk", # adjacent to script (clone target)
|
|
],
|
|
clone_url=None, # supply via --repo-url rtk=https://...
|
|
),
|
|
]
|
|
|
|
|
|
# ── Config defaults ───────────────────────────────────────────────────────────
|
|
# Reset before each experiment AND on script exit (atexit), preventing config leaks.
|
|
|
|
DEFAULTS: dict[str, str] = {
|
|
"edge_weight_calls": "1.0",
|
|
"edge_weight_usage": "0.7",
|
|
"edge_weight_defines": "0.1",
|
|
"edge_weight_tests": "0.05",
|
|
"edge_weight_imports": "0.3",
|
|
"key_functions_count": "25",
|
|
"key_functions_exclude": "",
|
|
"pagerank_max_iter": "20",
|
|
}
|
|
|
|
|
|
# ── Experiment definitions ────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class Experiment:
|
|
label: str
|
|
overrides: dict[str, str] = field(default_factory=dict)
|
|
notes: str = ""
|
|
|
|
|
|
EXPERIMENTS: list[Experiment] = [
|
|
Experiment("baseline_25",
|
|
{"key_functions_count": "25"},
|
|
"Default config, just raise count from 10 to 25"),
|
|
Experiment("exclude_ui",
|
|
{"key_functions_count": "25",
|
|
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**"},
|
|
"Filter TypeScript UI and tooling — exposes C core functions"),
|
|
Experiment("calls_boost",
|
|
{"key_functions_count": "25",
|
|
"edge_weight_calls": "2.0",
|
|
"edge_weight_usage": "0.3"},
|
|
"Boost direct call edges, dampen type-reference edges"),
|
|
Experiment("usage_dampen",
|
|
{"key_functions_count": "25",
|
|
"edge_weight_usage": "0.3",
|
|
"edge_weight_defines": "0.05"},
|
|
"Dampen usage and define weights"),
|
|
Experiment("tests_kill",
|
|
{"key_functions_count": "25",
|
|
"edge_weight_tests": "0.01",
|
|
"edge_weight_usage": "0.3"},
|
|
"Suppress test-file influence on production rankings"),
|
|
Experiment("calls_boost_excl",
|
|
{"key_functions_count": "25",
|
|
"edge_weight_calls": "2.0",
|
|
"edge_weight_usage": "0.3",
|
|
"key_functions_exclude": "graph-ui/**,tools/**,scripts/**"},
|
|
"Combined: boost calls + exclude UI"),
|
|
Experiment("more_iters",
|
|
{"key_functions_count": "25",
|
|
"pagerank_max_iter": "100"},
|
|
"More PageRank iterations for convergence on large graphs"),
|
|
]
|
|
|
|
|
|
# ── Repo discovery ────────────────────────────────────────────────────────────
|
|
|
|
def _resolve_repo(repo: Repo, clone: bool,
|
|
extra_urls: dict[str, str]) -> Path | None:
|
|
"""Return the first existing candidate path, or clone if requested.
|
|
|
|
Resolution order:
|
|
1. Check candidate_paths in order — first existing dir wins.
|
|
2. If none found and --clone is set: clone using extra_urls[name] or
|
|
repo.clone_url into the last candidate path (script-adjacent dir).
|
|
3. If no URL available, print a hint and return None.
|
|
"""
|
|
for path in repo.candidate_paths:
|
|
if path.is_dir():
|
|
return path
|
|
|
|
clone_url = extra_urls.get(repo.name) or repo.clone_url
|
|
if not clone_url:
|
|
print(f" [info] '{repo.name}' not found at any candidate path.")
|
|
print(f" Tried: {[str(p) for p in repo.candidate_paths]}")
|
|
print(f" Supply a URL with: --repo-url {repo.name}=https://github.com/user/{repo.name}")
|
|
if not clone:
|
|
print(f" Or pass --clone to auto-clone once a URL is set.")
|
|
return None
|
|
|
|
if not clone:
|
|
print(f" [info] '{repo.name}' not found. Pass --clone to auto-clone from {clone_url}")
|
|
return None
|
|
|
|
target = repo.candidate_paths[-1] # script-adjacent dir as clone target
|
|
print(f" [clone] {repo.name} -> {target} (from {clone_url})")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
result = subprocess.run(
|
|
["git", "clone", "--depth=1", clone_url, str(target)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(f" [error] clone failed: {result.stderr.strip()}", file=sys.stderr)
|
|
return None
|
|
return target
|
|
|
|
|
|
# ── JSON-RPC helpers ──────────────────────────────────────────────────────────
|
|
|
|
def _jsonrpc(req_id: int, method: str, params: dict[str, Any] | None = None) -> str:
|
|
msg: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
if params:
|
|
msg["params"] = params
|
|
return json.dumps(msg)
|
|
|
|
|
|
def _send_batch(binary: str, messages: list[str], timeout: int) -> dict[int, Any]:
|
|
"""Send newline-delimited JSON-RPC to the binary via stdin, parse stdout responses."""
|
|
payload = "\n".join(messages) + "\n"
|
|
try:
|
|
proc = subprocess.run(
|
|
[binary],
|
|
input=payload.encode(),
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print(f" [warn] binary timed out after {timeout}s — "
|
|
"raise --timeout for first-time indexing", file=sys.stderr)
|
|
return {}
|
|
except FileNotFoundError:
|
|
print(f" [error] binary not found: {binary}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
responses: dict[int, Any] = {}
|
|
for line in proc.stdout.decode(errors="replace").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
r = json.loads(line)
|
|
if "id" in r:
|
|
responses[r["id"]] = r
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return responses
|
|
|
|
|
|
def query_architecture(binary: str, repo_root: str, timeout: int,
|
|
retries: int = 2) -> list[dict[str, Any]]:
|
|
"""Query codebase://architecture, return key_functions list.
|
|
|
|
Retries on empty results: the binary may still be indexing on first call.
|
|
"""
|
|
init = _jsonrpc(1, "initialize", {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"resources": {}},
|
|
"clientInfo": {"name": "autotune", "version": "1.0"},
|
|
"rootUri": f"file://{repo_root}",
|
|
})
|
|
read = _jsonrpc(2, "resources/read", {"uri": "codebase://architecture"})
|
|
|
|
for attempt in range(retries + 1):
|
|
responses = _send_batch(binary, [init, read], timeout)
|
|
r2 = responses.get(2, {})
|
|
contents = r2.get("result", {}).get("contents", [])
|
|
if contents:
|
|
try:
|
|
data = json.loads(contents[0].get("text", "{}"))
|
|
kf = data.get("key_functions", [])
|
|
if kf:
|
|
return kf
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
if attempt < retries:
|
|
wait = 3 * (attempt + 1)
|
|
print(f" [retry {attempt + 1}/{retries}] empty results — "
|
|
f"waiting {wait}s (repo may still be indexing)...")
|
|
time.sleep(wait)
|
|
|
|
return []
|
|
|
|
|
|
def set_config(binary: str, key: str, value: str, timeout: int = 10) -> None:
|
|
"""Set a config value via binary CLI: `binary config set key value`."""
|
|
try:
|
|
subprocess.run(
|
|
[binary, "config", "set", key, value],
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print(f" [warn] config set {key!r} timed out", file=sys.stderr)
|
|
|
|
|
|
def reset_to_defaults(binary: str) -> None:
|
|
"""Reset all tunable config keys to baseline defaults.
|
|
|
|
Called before each experiment and registered with atexit so no stale config
|
|
persists after a crash or KeyboardInterrupt.
|
|
"""
|
|
for k, v in DEFAULTS.items():
|
|
set_config(binary, k, v)
|
|
|
|
|
|
# ── Scoring ───────────────────────────────────────────────────────────────────
|
|
|
|
def score_result(key_functions: list[dict[str, Any]], expected: list[str]) -> int:
|
|
"""Count how many expected names appear in key_functions (case-insensitive)."""
|
|
names: set[str] = set()
|
|
for kf in key_functions:
|
|
name = kf.get("name", "")
|
|
if name:
|
|
names.add(name.lower())
|
|
qn = kf.get("qualified_name", "")
|
|
if qn:
|
|
# Qualified names encode full paths; take the last segment
|
|
names.add(qn.split(".")[-1].lower())
|
|
return sum(1 for e in expected if e.lower() in names)
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Auto-tune codebase-memory-mcp ranking via JSON-RPC.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Examples:\n"
|
|
" python3 scripts/autotune.py\n"
|
|
" python3 scripts/autotune.py --timeout 120 # for first-time indexing\n"
|
|
" python3 scripts/autotune.py --clone --repo-url rtk=https://github.com/user/rtk\n"
|
|
" python3 scripts/autotune.py --binary /usr/local/bin/codebase-memory-mcp\n"
|
|
"\n"
|
|
"NOTE: Config changes are global (stored in the binary's SQLite DB).\n"
|
|
" Stop any running codebase-memory-mcp MCP server before running autotune,\n"
|
|
" or accept that the server will use whatever config autotune is currently testing.\n"
|
|
" All config is reset to defaults on exit (including Ctrl-C).\n"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--binary",
|
|
default=str(Path.home() / ".local/bin/codebase-memory-mcp"),
|
|
help="Path to binary (default: ~/.local/bin/codebase-memory-mcp)",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=60,
|
|
help="Seconds before JSON-RPC times out (default: 60; raise for first-time indexing)",
|
|
)
|
|
parser.add_argument(
|
|
"--clone",
|
|
action="store_true",
|
|
help="Auto-clone missing repos (requires --repo-url or clone_url set in REPOS)",
|
|
)
|
|
parser.add_argument(
|
|
"--repo-url",
|
|
action="append",
|
|
default=[],
|
|
metavar="NAME=URL",
|
|
help="Clone URL for a repo, e.g. --repo-url rtk=https://github.com/user/rtk "
|
|
"(can be repeated for multiple repos)",
|
|
)
|
|
args = parser.parse_args()
|
|
binary = args.binary
|
|
|
|
# Parse --repo-url NAME=URL pairs into a dict
|
|
extra_urls: dict[str, str] = {}
|
|
for item in args.repo_url:
|
|
if "=" in item:
|
|
name, url = item.split("=", 1)
|
|
extra_urls[name.strip()] = url.strip()
|
|
else:
|
|
print(f"[warn] --repo-url {item!r} ignored: expected NAME=URL format",
|
|
file=sys.stderr)
|
|
|
|
if not Path(binary).is_file():
|
|
print(f"Error: binary not found: {binary}", file=sys.stderr)
|
|
print("Build with: env -i HOME=$HOME PATH=$PATH make -f Makefile.cbm cbm",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Resolve repos before experiments (discovery/cloning happens once)
|
|
resolved: list[tuple[Repo, Path]] = []
|
|
for repo in REPOS:
|
|
path = _resolve_repo(repo, args.clone, extra_urls)
|
|
if path is not None:
|
|
resolved.append((repo, path))
|
|
|
|
if not resolved:
|
|
print("Error: no repos found. Use --clone with --repo-url, or place repos at "
|
|
"the candidate paths listed above.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Always reset config on exit — even after Ctrl-C or crash
|
|
atexit.register(reset_to_defaults, binary)
|
|
|
|
total_expected = sum(len(repo.expected) for repo, _ in resolved)
|
|
print(f"Binary: {binary}")
|
|
print(f"Repos: {[(repo.name, str(path)) for repo, path in resolved]}")
|
|
print(f"Timeout: {args.timeout}s per query")
|
|
print(f"Max score: {total_expected} ({len(resolved)} repos x ~10 each)\n")
|
|
|
|
best_experiment: Experiment | None = None
|
|
best_score = -1
|
|
all_results: list[tuple[str, int]] = []
|
|
|
|
for exp in EXPERIMENTS:
|
|
print(f"\n=== {exp.label} ===")
|
|
if exp.notes:
|
|
print(f" ({exp.notes})")
|
|
reset_to_defaults(binary)
|
|
for k, v in exp.overrides.items():
|
|
set_config(binary, k, v)
|
|
print(f" config set {k} = {v!r}")
|
|
|
|
total_score = 0
|
|
for repo, repo_path in resolved:
|
|
kf = query_architecture(binary, str(repo_path), args.timeout)
|
|
if not kf:
|
|
print(f" [warn] {repo.name}: no key_functions returned — "
|
|
"ensure repo is indexed: codebase-memory-mcp index <path>")
|
|
continue
|
|
score = score_result(kf, repo.expected)
|
|
total_score += score
|
|
top5 = [kf_item.get("name") or kf_item.get("qualified_name", "?")
|
|
for kf_item in kf[:5]]
|
|
print(f" {repo.name}: {score}/{len(repo.expected)} top-5: {top5}")
|
|
|
|
print(f" TOTAL: {total_score}/{total_expected}")
|
|
all_results.append((exp.label, total_score))
|
|
if total_score > best_score:
|
|
best_score = total_score
|
|
best_experiment = exp
|
|
|
|
print("\n" + "=" * 60)
|
|
if best_experiment is None:
|
|
print("No experiments produced results. Ensure repos are indexed.")
|
|
print("Index a repo: codebase-memory-mcp index <path>")
|
|
return
|
|
|
|
print(f"BEST: {best_experiment.label} score={best_score}/{total_expected}")
|
|
if best_experiment.notes:
|
|
print(f" ({best_experiment.notes})")
|
|
print("\nApply permanently:")
|
|
for k, v in best_experiment.overrides.items():
|
|
print(f" codebase-memory-mcp config set {k} {v!r}")
|
|
|
|
print("\nAll results (best first):")
|
|
for label, score in sorted(all_results, key=lambda x: x[1], reverse=True):
|
|
marker = " <" if label == best_experiment.label else ""
|
|
print(f" {score:3d}/{total_expected} {label}{marker}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|