verd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
verd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: verd
3
+ Version: 0.1.0
4
+ Summary: Multi-LLM debate CLI for confident answers
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: openai>=1.0.0
7
+ Requires-Dist: rich>=13.0.0
8
+ Requires-Dist: python-dotenv>=1.0.0
9
+ Requires-Dist: mcp>=1.0.0
10
+ Provides-Extra: slack
11
+ Requires-Dist: slack-bolt>=1.18.0; extra == "slack"
12
+ Requires-Dist: httpx>=0.27.0; extra == "slack"
verd-0.1.0/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # verd
2
+
3
+ Multi-LLM debate for confident answers. Takes any content + a question, runs it through multiple AI models in a structured multi-round debate, and returns a confidence-weighted verdict with strengths, issues, and fixes.
4
+
5
+ Instead of asking one AI "are you sure?", verd spawns multiple models from different families, has them challenge each other across rounds, then a stronger judge synthesizes the final verdict.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install verd
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ verd works with any OpenAI-compatible API. Pick one:
16
+
17
+ ### Option 1: OpenRouter (easiest, all models, one key)
18
+
19
+ Sign up at [openrouter.ai](https://openrouter.ai), get an API key, then:
20
+
21
+ ```bash
22
+ export OPENAI_API_KEY=sk-or-...
23
+ export OPENAI_BASE_URL=https://openrouter.ai/api/v1
24
+ ```
25
+
26
+ ### Option 2: Direct OpenAI
27
+
28
+ ```bash
29
+ export OPENAI_API_KEY=sk-...
30
+ export OPENAI_BASE_URL=https://api.openai.com/v1
31
+ ```
32
+
33
+ Note: only OpenAI models will work. Edit `verd/models.py` to use only OpenAI models.
34
+
35
+ ### Option 3: LiteLLM proxy (use native keys from any provider)
36
+
37
+ If you have API keys from multiple providers (Anthropic, Google, OpenAI, etc.):
38
+
39
+ ```bash
40
+ pip install litellm
41
+ litellm --config litellm_config.yaml # starts local proxy on port 4000
42
+ ```
43
+
44
+ Example `litellm_config.yaml`:
45
+ ```yaml
46
+ model_list:
47
+ - model_name: claude-sonnet-4-6
48
+ litellm_params:
49
+ model: anthropic/claude-sonnet-4-20250514
50
+ api_key: sk-ant-...
51
+ - model_name: gpt-5-mini
52
+ litellm_params:
53
+ model: openai/gpt-5-mini
54
+ api_key: sk-...
55
+ - model_name: gemini-2.5-flash
56
+ litellm_params:
57
+ model: gemini/gemini-2.5-flash
58
+ api_key: AIza...
59
+ ```
60
+
61
+ Then point verd at it:
62
+ ```bash
63
+ export OPENAI_API_KEY=sk-anything
64
+ export OPENAI_BASE_URL=http://localhost:4000/v1
65
+ ```
66
+
67
+ ### Option 4: Any OpenAI-compatible provider
68
+
69
+ Azure OpenAI, Together, Groq, Fireworks, etc. — just set the base URL and API key.
70
+
71
+ ### Save to .env
72
+
73
+ Or create a `.env` file in your working directory:
74
+ ```bash
75
+ cp .env.example .env
76
+ # edit with your keys
77
+ ```
78
+
79
+ ### Custom models
80
+
81
+ Edit `verd/models.py` to match whatever models your provider supports. The default config uses models available through LiteLLM proxies and OpenRouter.
82
+
83
+ ## Usage
84
+
85
+ ```bash
86
+ # Auto-scan current directory
87
+ cd backend && verd "is this production-ready?"
88
+
89
+ # Single file
90
+ verd "is this JWT implementation secure?" -f auth.py
91
+
92
+ # Multiple files
93
+ verd "any issues?" -f auth.py middleware.py routes.py
94
+
95
+ # Directory
96
+ verd "is this codebase sound?" -d src/ --ext .py
97
+
98
+ # Inline question
99
+ verdl "is O(n^2) acceptable for n=1000?"
100
+
101
+ # Git diffs
102
+ verd "are these changes safe?" -g # unstaged
103
+ verd "ready to commit?" -gs # staged
104
+ verdh "should we merge this?" -gb main # branch diff
105
+
106
+ # Pipe
107
+ cat auth.py | verd "is this secure?"
108
+
109
+ # Quiet mode (verdict only, no transcript)
110
+ verd "any bugs?" -f app.py -q
111
+
112
+ # JSON output
113
+ verd "any bugs?" -f app.py --json
114
+ ```
115
+
116
+ ## Modes
117
+
118
+ | Command | Models | Rounds | Speed | Cost |
119
+ |---------|--------|--------|-------|------|
120
+ | `verdl` | 2 | 1 | ~10s | ~$0.01 |
121
+ | `verd` | 4 | 2 | ~30s | ~$0.05 |
122
+ | `verdh` | 5 + web search | 3 | ~70s | ~$0.30 |
123
+
124
+ ## Flags
125
+
126
+ ```
127
+ claim the question to evaluate (required)
128
+
129
+ Content input (pick one, or auto-scans current dir):
130
+ -c, --context TEXT inline content string
131
+ -f FILE [FILE ...] one or more files
132
+ -d [DIR] directory (default: current dir)
133
+ -g, --git unstaged git diff
134
+ -gs, --git-staged staged git diff
135
+ -gb, --git-branch REF git diff REF...HEAD
136
+
137
+ Directory filters:
138
+ --ext EXT [EXT ...] filter by extension (.py .ts)
139
+ --exclude PATTERN glob pattern to exclude (test_*)
140
+
141
+ Output:
142
+ -q, --quiet hide debate transcript, show only verdict
143
+ --json raw JSON output
144
+ --timeout SECONDS override timeout per model call
145
+ --version show version
146
+ ```
147
+
148
+ ## Exit Codes
149
+
150
+ - `0` — PASS
151
+ - `1` — FAIL
152
+ - `2` — UNCERTAIN
153
+
154
+ Useful for scripting: `verd "are tests passing?" -f test.py && deploy`
155
+
156
+ ## MCP — Claude Code / Cursor
157
+
158
+ Add to `~/.claude.json` or `~/.cursor/mcp.json`:
159
+
160
+ ```json
161
+ {
162
+ "mcpServers": {
163
+ "verd": {
164
+ "command": "verd-mcp",
165
+ "env": {
166
+ "OPENAI_API_KEY": "your-key",
167
+ "OPENAI_BASE_URL": "https://openrouter.ai/api/v1"
168
+ }
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Then use `verd`, `verdl`, or `verdh` as tools directly in chat.
175
+
176
+ ## Slack
177
+
178
+ Install with Slack dependencies:
179
+
180
+ ```bash
181
+ pip install verd[slack]
182
+ ```
183
+
184
+ Create a Slack app with Socket Mode, add bot scopes (`app_mentions:read`, `channels:history`, `chat:write`, `reactions:write`, `im:history`, `im:write`, `users:read`), then:
185
+
186
+ ```bash
187
+ export SLACK_BOT_TOKEN=xoxb-...
188
+ export SLACK_APP_TOKEN=xapp-...
189
+ export SLACK_SIGNING_SECRET=...
190
+ verd-slack
191
+ ```
192
+
193
+ Usage in Slack:
194
+ - `@verd what do you think?` — reads thread/channel context, debates, replies
195
+ - `@verd deep is this secure?` — uses verdh (5 models + web search)
196
+ - `@verd quick is this right?` — uses verdl (fast)
197
+ - `@verd last 50 what's the consensus?` — reads last 50 messages
198
+ - `/verd should we use Kafka?` — slash command with progress updates
199
+ - `/verdl is this correct?` — quick slash command
200
+ - `/verdh any security issues?` — deep slash command
201
+
202
+ ## How it works
203
+
204
+ 1. Your question + content gets sent to multiple AI models in parallel
205
+ 2. Each model gives its independent assessment (PASS/FAIL/UNCERTAIN)
206
+ 3. Models see each other's responses and cross-examine for 1-3 rounds
207
+ 4. A stronger judge model synthesizes the debate into a final verdict
208
+ 5. You get: verdict, confidence %, strengths, issues, and actionable fixes
209
+
210
+ The key insight: different models catch different things. Claude spots security issues GPT misses. Gemini catches logic errors DeepSeek overlooks. The debate format forces them to challenge each other rather than just agreeing.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "verd"
7
+ version = "0.1.0"
8
+ description = "Multi-LLM debate CLI for confident answers"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "openai>=1.0.0",
12
+ "rich>=13.0.0",
13
+ "python-dotenv>=1.0.0",
14
+ "mcp>=1.0.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ slack = [
19
+ "slack-bolt>=1.18.0",
20
+ "httpx>=0.27.0",
21
+ ]
22
+
23
+ [project.scripts]
24
+ verd = "verd.__main__:main_default"
25
+ verdl = "verd.__main__:main_light"
26
+ verdh = "verd.__main__:main_heavy"
27
+ verd-mcp = "verd.mcp_server:main"
28
+ verd-slack = "verd.slack_bot:main"
verd-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,141 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import sys
5
+
6
+ from verd.config import VERSION
7
+ from verd.context import build_context
8
+ from verd.engine import run_debate
9
+ from verd.output import print_result, StatusDisplay
10
+
11
+
12
+ def make_parser():
13
+ parser = argparse.ArgumentParser(
14
+ description="Multi-LLM debate for confident answers"
15
+ )
16
+ parser.add_argument("--version", action="version", version=f"verd {VERSION}")
17
+ parser.add_argument("claim", help="The claim or question to evaluate")
18
+
19
+ # Content input — pick one
20
+ content = parser.add_argument_group("content input")
21
+ content.add_argument("-c", "--context", help="Inline content string")
22
+ content.add_argument(
23
+ "-f", "--file", nargs="+", metavar="FILE",
24
+ help="One or more files to evaluate",
25
+ )
26
+ content.add_argument(
27
+ "-d", "--dir", nargs="?", const="", default=None, metavar="DIR",
28
+ help="Read all files in a directory (default: current dir)",
29
+ )
30
+ content.add_argument(
31
+ "-g", "--git", action="store_true",
32
+ help="Use unstaged git diff as content",
33
+ )
34
+ content.add_argument(
35
+ "-gs", "--git-staged", action="store_true",
36
+ help="Use staged git diff as content",
37
+ )
38
+ content.add_argument(
39
+ "-gb", "--git-branch", metavar="REF",
40
+ help="Use git diff REF...HEAD as content (e.g. main)",
41
+ )
42
+
43
+ # Filters for -d
44
+ filters = parser.add_argument_group("directory filters (use with -d)")
45
+ filters.add_argument(
46
+ "-a", "--all", action="store_true",
47
+ help="Scan all files, skip smart selection (for full codebase reviews)",
48
+ )
49
+ filters.add_argument(
50
+ "--ext", nargs="+", metavar="EXT",
51
+ help="Filter by extension (e.g. --ext .py .ts)",
52
+ )
53
+ filters.add_argument(
54
+ "--exclude", nargs="+", metavar="PATTERN",
55
+ help="Glob patterns to exclude (e.g. --exclude 'test_*' '*.spec.*')",
56
+ )
57
+
58
+ # Output
59
+ parser.add_argument(
60
+ "-q", "--quiet", action="store_true",
61
+ help="Hide debate transcript, show only verdict",
62
+ )
63
+ parser.add_argument(
64
+ "--json", dest="json_output", action="store_true",
65
+ help="Output raw JSON",
66
+ )
67
+ parser.add_argument(
68
+ "--timeout", type=int, default=None,
69
+ help="Override timeout per model call (seconds)",
70
+ )
71
+ return parser
72
+
73
+
74
+ def run(mode: str):
75
+ parser = make_parser()
76
+ args = parser.parse_args()
77
+ content, claim, files = build_context(args)
78
+
79
+ if not content and not claim:
80
+ parser.print_help()
81
+ sys.exit(1)
82
+
83
+ # --all flag: skip smart file selection, send everything
84
+ if getattr(args, 'all', False) and files is not None:
85
+ from verd.context import files_to_content, MAX_CONTENT_CHARS
86
+ total_chars = sum(len(text) for _, text, _ in files)
87
+ content = files_to_content(files)
88
+ if total_chars > MAX_CONTENT_CHARS:
89
+ skipped = total_chars - MAX_CONTENT_CHARS
90
+ print(
91
+ f"\n⚠ {len(files)} files totaling {total_chars // 1000}K chars — "
92
+ f"truncated to {MAX_CONTENT_CHARS // 1000}K ({skipped // 1000}K chars lost).\n"
93
+ f" Some files were cut off. For better results, drop -a and let\n"
94
+ f" the smart selector pick relevant files, or narrow with --ext / -f.\n",
95
+ file=sys.stderr,
96
+ )
97
+ else:
98
+ print(f"scanning all {len(files)} files ({total_chars // 1000}K chars)", file=sys.stderr)
99
+ files = None # don't run selector
100
+
101
+ status = StatusDisplay()
102
+ try:
103
+ result = asyncio.run(
104
+ run_debate(
105
+ content,
106
+ claim,
107
+ mode,
108
+ timeout_override=args.timeout,
109
+ status_display=status,
110
+ files=files,
111
+ verbose=not args.quiet,
112
+ )
113
+ )
114
+ finally:
115
+ status.stop()
116
+
117
+ if args.json_output:
118
+ print(json.dumps(result, indent=2, default=str))
119
+ else:
120
+ print_result(result)
121
+
122
+ # Exit codes: 0=PASS, 1=FAIL, 2=UNCERTAIN
123
+ verdict = result.get("verdict", "UNCERTAIN")
124
+ exit_codes = {"PASS": 0, "FAIL": 1, "UNCERTAIN": 2}
125
+ sys.exit(exit_codes.get(verdict, 2))
126
+
127
+
128
+ def main_light():
129
+ run("verdl")
130
+
131
+
132
+ def main_default():
133
+ run("verd")
134
+
135
+
136
+ def main_heavy():
137
+ run("verdh")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main_default()
@@ -0,0 +1,68 @@
1
+ import os
2
+ from openai import AsyncOpenAI
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ VERSION = "0.1.0"
8
+
9
+ # Token budgets — reasoning models use these for thinking + output combined
10
+ # Keep high enough that reasoning models don't run out of thinking space
11
+ DEBATER_MAX_TOKENS = {
12
+ "verdl": 2048, # fast, concise
13
+ "verd": 4096, # balanced
14
+ "verdh": 4096, # deep analysis
15
+ }
16
+ JUDGE_MAX_TOKENS = 8192
17
+
18
+ TIMEOUTS = {
19
+ "verdl": 30,
20
+ "verd": 45,
21
+ "verdh": 90,
22
+ }
23
+
24
+ JSON_MODE_MODELS = {
25
+ "o3", "o3-mini", "o4-mini", "gpt-4.1", "gpt-5-mini", "gpt-5", "gpt-5.4"
26
+ }
27
+
28
+ # Pricing per 1M tokens (input, output) in USD
29
+ MODEL_PRICING = {
30
+ "gpt-4.1": (2.00, 8.00),
31
+ "gpt-5-mini": (0.25, 2.00),
32
+ "gpt-5.4": (2.50, 15.00),
33
+ "claude-sonnet-4-6": (3.00, 15.00),
34
+ "claude-opus-4-6": (5.00, 25.00),
35
+ "gemini-2.5-flash": (0.30, 2.50),
36
+ "gemini-3.1-pro-preview": (2.00, 12.00),
37
+ "deepseek-r1": (1.35, 5.40),
38
+ "sonar-pro": (3.00, 15.00),
39
+ "o3": (2.00, 8.00),
40
+ "o4-mini": (1.10, 4.40),
41
+ }
42
+
43
+
44
+ def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
45
+ """Estimate cost in USD for a model call."""
46
+ input_price, output_price = MODEL_PRICING.get(model, (5.00, 15.00))
47
+ return (prompt_tokens * input_price + completion_tokens * output_price) / 1_000_000
48
+
49
+
50
+ _client = None
51
+
52
+
53
+ def get_client() -> AsyncOpenAI:
54
+ """Lazy client init — only validates env vars on first actual API call."""
55
+ global _client
56
+ if _client is None:
57
+ api_key = os.getenv("OPENAI_API_KEY")
58
+ base_url = os.getenv("OPENAI_BASE_URL")
59
+ if not api_key:
60
+ raise EnvironmentError(
61
+ "OPENAI_API_KEY is not set. Add it to your .env file or export it."
62
+ )
63
+ if not base_url:
64
+ raise EnvironmentError(
65
+ "OPENAI_BASE_URL is not set. Add it to your .env file or export it."
66
+ )
67
+ _client = AsyncOpenAI(api_key=api_key, base_url=base_url)
68
+ return _client
@@ -0,0 +1,153 @@
1
+ import fnmatch
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # Dirs/files to always skip
7
+ _SKIP_DIRS = {
8
+ "__pycache__", ".git", ".venv", "venv", "node_modules",
9
+ ".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
10
+ ".egg-info", ".eggs",
11
+ }
12
+ _SKIP_FILES = {
13
+ ".DS_Store", "Thumbs.db", ".env", ".env.local",
14
+ }
15
+
16
+ MAX_CONTENT_CHARS = 200_000 # ~50k tokens, safe for most models
17
+
18
+ # Code extensions we care about
19
+ _CODE_EXTENSIONS = {
20
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".rb", ".java",
21
+ ".kt", ".swift", ".c", ".cpp", ".h", ".hpp", ".cs", ".php",
22
+ ".scala", ".ex", ".exs", ".clj", ".lua", ".r", ".sql", ".sh",
23
+ ".yaml", ".yml", ".toml", ".json", ".tf", ".hcl",
24
+ }
25
+
26
+
27
+ def _is_valid_path(p: Path) -> bool:
28
+ if any(part in _SKIP_DIRS for part in p.parts):
29
+ return False
30
+ if p.name in _SKIP_FILES or p.name.startswith("."):
31
+ return False
32
+ return True
33
+
34
+
35
+ def _read_file(path: Path) -> str | None:
36
+ """Read file content, return None on failure."""
37
+ try:
38
+ return path.read_text()
39
+ except (UnicodeDecodeError, PermissionError):
40
+ return None
41
+
42
+
43
+ def _collect_files(
44
+ dir_path: Path,
45
+ extensions: list[str] | None,
46
+ excludes: list[str] | None,
47
+ ) -> list[tuple[Path, str, str]]:
48
+ """Collect files from directory. Returns list of (relative_path, content, extension).
49
+
50
+ Auto-detects extensions if None.
51
+ """
52
+ if extensions is None:
53
+ counts: dict[str, int] = {}
54
+ for p in dir_path.rglob("*"):
55
+ if not p.is_file() or not _is_valid_path(p):
56
+ continue
57
+ if p.suffix in _CODE_EXTENSIONS:
58
+ counts[p.suffix] = counts.get(p.suffix, 0) + 1
59
+ if not counts:
60
+ return []
61
+ extensions = sorted(counts, key=counts.get, reverse=True)
62
+ pass # auto-detected extensions
63
+
64
+ files = []
65
+ for p in sorted(dir_path.rglob("*")):
66
+ if not p.is_file() or not _is_valid_path(p):
67
+ continue
68
+ if extensions and p.suffix not in extensions:
69
+ continue
70
+ if excludes and any(fnmatch.fnmatch(p.name, pat) for pat in excludes):
71
+ continue
72
+
73
+ text = _read_file(p)
74
+ if text is None:
75
+ continue
76
+
77
+ rel = p.relative_to(dir_path)
78
+ files.append((rel, text, p.suffix))
79
+
80
+ return files
81
+
82
+
83
+ def files_to_content(files: list[tuple[Path, str, str]]) -> str:
84
+ """Convert file list to concatenated content string with headers."""
85
+ parts = []
86
+ total = 0
87
+ for path, text, _ext in files:
88
+ chunk = f"--- {path} ---\n{text}\n"
89
+ total += len(chunk)
90
+ if total > MAX_CONTENT_CHARS:
91
+ parts.append(f"\n[truncated at {MAX_CONTENT_CHARS // 1000}k chars]\n")
92
+ break
93
+ parts.append(chunk)
94
+ return "\n".join(parts)
95
+
96
+
97
+ def _run_git(cmd: list[str]) -> str:
98
+ result = subprocess.run(cmd, capture_output=True, text=True)
99
+ if result.returncode != 0:
100
+ raise RuntimeError(f"git failed: {result.stderr.strip()}")
101
+ return result.stdout
102
+
103
+
104
+ def build_context(args) -> tuple[str, str, list[tuple[Path, str, str]] | None]:
105
+ """Returns (content, claim, files_or_none).
106
+
107
+ When files is not None, content selection can be applied before debate.
108
+ When files is None, content is already final (user picked it explicitly).
109
+ """
110
+ content = ""
111
+ files = None
112
+
113
+ if args.dir is not None:
114
+ dir_path = Path(args.dir) if args.dir else Path(".")
115
+ exts = args.ext or None
116
+ excludes = args.exclude or None
117
+ files = _collect_files(dir_path, exts, excludes)
118
+ content = files_to_content(files)
119
+ elif args.file:
120
+ parts = []
121
+ for f in args.file:
122
+ p = Path(f)
123
+ if not p.exists():
124
+ print(f"warning: {f} not found, skipping", file=sys.stderr)
125
+ continue
126
+ text = _read_file(p)
127
+ if text is not None:
128
+ parts.append(f"--- {p.name} ---\n{text}\n")
129
+ content = "\n".join(parts)
130
+ elif args.git:
131
+ content = _run_git(["git", "diff"])
132
+ elif args.git_staged:
133
+ content = _run_git(["git", "diff", "--staged"])
134
+ elif args.git_branch:
135
+ content = _run_git(["git", "diff", f"{args.git_branch}...HEAD"])
136
+ elif args.context:
137
+ content = args.context
138
+ elif not sys.stdin.isatty():
139
+ content = sys.stdin.read()
140
+ else:
141
+ # No content flag — auto-scan current directory
142
+ cwd = Path(".")
143
+ files = _collect_files(cwd, None, None)
144
+ if files:
145
+ content = files_to_content(files)
146
+
147
+ content = content.strip()
148
+
149
+ if len(content) > MAX_CONTENT_CHARS:
150
+ content = content[:MAX_CONTENT_CHARS] + f"\n[truncated at {MAX_CONTENT_CHARS // 1000}k chars]"
151
+ print(f"warning: content truncated to {MAX_CONTENT_CHARS // 1000}k chars", file=sys.stderr)
152
+
153
+ return content, args.claim.strip(), files