loopllm 0.7.0__py3-none-any.whl
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.
- loopllm/__init__.py +69 -0
- loopllm/__main__.py +5 -0
- loopllm/adaptive_exit.py +78 -0
- loopllm/agent_loop.py +299 -0
- loopllm/cli.py +521 -0
- loopllm/elicitation.py +519 -0
- loopllm/engine.py +376 -0
- loopllm/evaluator_factory.py +72 -0
- loopllm/evaluators.py +419 -0
- loopllm/guards.py +254 -0
- loopllm/local_loop.py +273 -0
- loopllm/mcp_server.py +2657 -0
- loopllm/plan_registry.py +412 -0
- loopllm/priors.py +604 -0
- loopllm/provider.py +51 -0
- loopllm/providers/__init__.py +15 -0
- loopllm/providers/agent.py +64 -0
- loopllm/providers/mock.py +64 -0
- loopllm/providers/ollama.py +95 -0
- loopllm/providers/openrouter.py +101 -0
- loopllm/serve.py +297 -0
- loopllm/step_scorer.py +190 -0
- loopllm/store.py +1126 -0
- loopllm/tasks.py +599 -0
- loopllm-0.7.0.dist-info/METADATA +454 -0
- loopllm-0.7.0.dist-info/RECORD +29 -0
- loopllm-0.7.0.dist-info/WHEEL +4 -0
- loopllm-0.7.0.dist-info/entry_points.txt +3 -0
- loopllm-0.7.0.dist-info/licenses/LICENSE +21 -0
loopllm/cli.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Command-line interface for loop-llm."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from loopllm.elicitation import ClarifyingQuestion, IntentRefiner
|
|
11
|
+
from loopllm.engine import LoopConfig, LoopedLLM
|
|
12
|
+
from loopllm.evaluators import LengthEvaluator
|
|
13
|
+
from loopllm.priors import CallObservation
|
|
14
|
+
from loopllm.provider import LLMProvider
|
|
15
|
+
from loopllm.store import LoopStore, SQLiteBackedPriors
|
|
16
|
+
from loopllm.tasks import TaskOrchestrator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_provider(name: str, **kwargs: Any) -> LLMProvider:
|
|
20
|
+
"""Instantiate an LLM provider by name.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
name: Provider name (``"mock"``, ``"ollama"``, or ``"openrouter"``).
|
|
24
|
+
**kwargs: Extra keyword arguments forwarded to the provider constructor.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
An :class:`LLMProvider` instance.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
SystemExit: If the provider name is unknown.
|
|
31
|
+
"""
|
|
32
|
+
if name == "mock":
|
|
33
|
+
from loopllm.providers.mock import MockLLMProvider
|
|
34
|
+
|
|
35
|
+
responses = [
|
|
36
|
+
'{"result": "initial attempt"}',
|
|
37
|
+
'{"result": "improved version", "details": "added more info"}',
|
|
38
|
+
'{"result": "refined output", "details": "comprehensive", "quality": "high"}',
|
|
39
|
+
]
|
|
40
|
+
return MockLLMProvider(responses=responses)
|
|
41
|
+
elif name == "ollama":
|
|
42
|
+
from loopllm.providers.ollama import OllamaProvider
|
|
43
|
+
|
|
44
|
+
return OllamaProvider(base_url=kwargs.get("base_url", "http://localhost:11434"))
|
|
45
|
+
elif name == "openrouter":
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
from loopllm.providers.openrouter import OpenRouterProvider
|
|
49
|
+
|
|
50
|
+
api_key = kwargs.get("api_key") or os.environ.get("OPENROUTER_API_KEY", "")
|
|
51
|
+
if not api_key:
|
|
52
|
+
print("Error: OPENROUTER_API_KEY not set", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
return OpenRouterProvider(api_key=api_key)
|
|
55
|
+
else:
|
|
56
|
+
print(f"Unknown provider: {name}", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_store(db_path: str | None) -> LoopStore:
|
|
61
|
+
"""Create a LoopStore, defaulting to ~/.loopllm/store.db."""
|
|
62
|
+
if db_path:
|
|
63
|
+
path = Path(db_path)
|
|
64
|
+
else:
|
|
65
|
+
path = Path.home() / ".loopllm" / "store.db"
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return LoopStore(db_path=path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _interactive_answer(question: ClarifyingQuestion) -> str:
|
|
71
|
+
"""Prompt the user for an answer to a clarifying question."""
|
|
72
|
+
print(f"\n [{question.question_type.upper()}] {question.text}")
|
|
73
|
+
if question.options:
|
|
74
|
+
for i, opt in enumerate(question.options, 1):
|
|
75
|
+
print(f" {i}. {opt}")
|
|
76
|
+
print(" (enter number or free text)")
|
|
77
|
+
|
|
78
|
+
answer = input(" > ").strip()
|
|
79
|
+
|
|
80
|
+
# If they entered a number and we have options, map it
|
|
81
|
+
if question.options and answer.isdigit():
|
|
82
|
+
idx = int(answer) - 1
|
|
83
|
+
if 0 <= idx < len(question.options):
|
|
84
|
+
answer = question.options[idx]
|
|
85
|
+
|
|
86
|
+
return answer
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_refine(args: argparse.Namespace) -> None:
|
|
90
|
+
"""Execute the ``refine`` subcommand: elicit intent then refine."""
|
|
91
|
+
provider = _get_provider(args.provider)
|
|
92
|
+
store = _get_store(args.db)
|
|
93
|
+
priors = SQLiteBackedPriors(store)
|
|
94
|
+
|
|
95
|
+
prompt = args.prompt
|
|
96
|
+
model = args.model
|
|
97
|
+
|
|
98
|
+
if args.no_questions:
|
|
99
|
+
# Skip elicitation, go straight to refinement
|
|
100
|
+
print(f"Refining: {prompt[:80]}...")
|
|
101
|
+
else:
|
|
102
|
+
# Run intent elicitation
|
|
103
|
+
refiner = IntentRefiner(
|
|
104
|
+
provider=provider,
|
|
105
|
+
priors=priors,
|
|
106
|
+
model=model,
|
|
107
|
+
max_questions=args.max_questions,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
print(f"Analyzing prompt: {prompt[:80]}...")
|
|
111
|
+
session = refiner.run_session(prompt, answer_func=_interactive_answer)
|
|
112
|
+
|
|
113
|
+
if session.refined_spec:
|
|
114
|
+
print("\n--- Refined Spec ---")
|
|
115
|
+
print(f"Task type: {session.refined_spec.task_type}")
|
|
116
|
+
print(f"Complexity: {session.refined_spec.estimated_complexity:.1f}")
|
|
117
|
+
print(f"Prompt: {session.refined_spec.refined_prompt[:200]}")
|
|
118
|
+
if session.refined_spec.quality_criteria:
|
|
119
|
+
print("Quality criteria:")
|
|
120
|
+
for c in session.refined_spec.quality_criteria:
|
|
121
|
+
print(f" - {c}")
|
|
122
|
+
prompt = session.refined_spec.refined_prompt
|
|
123
|
+
|
|
124
|
+
# Run refinement loop
|
|
125
|
+
evaluator = LengthEvaluator(min_words=5, max_words=10_000)
|
|
126
|
+
config = LoopConfig(
|
|
127
|
+
max_iterations=args.max_iterations,
|
|
128
|
+
quality_threshold=args.threshold,
|
|
129
|
+
)
|
|
130
|
+
loop = LoopedLLM(provider=provider, config=config)
|
|
131
|
+
|
|
132
|
+
print(f"\nRunning refinement loop (max {config.max_iterations} iterations)...")
|
|
133
|
+
result = loop.refine(prompt, evaluator, model=model)
|
|
134
|
+
|
|
135
|
+
print("\n--- Result ---")
|
|
136
|
+
print(f"Exit: {result.metrics.exit_reason.condition}")
|
|
137
|
+
print(f"Iterations: {result.metrics.total_iterations}")
|
|
138
|
+
print(f"Best score: {result.metrics.best_score:.3f}")
|
|
139
|
+
print(f"Output:\n{result.output}")
|
|
140
|
+
|
|
141
|
+
# Record observation
|
|
142
|
+
obs = CallObservation(
|
|
143
|
+
task_type="cli_refine",
|
|
144
|
+
model_id=model,
|
|
145
|
+
scores=result.metrics.score_trajectory,
|
|
146
|
+
latencies_ms=[it.latency_ms for it in result.iterations],
|
|
147
|
+
converged=result.metrics.converged,
|
|
148
|
+
total_iterations=result.metrics.total_iterations,
|
|
149
|
+
max_iterations=config.max_iterations,
|
|
150
|
+
quality_threshold=config.quality_threshold,
|
|
151
|
+
)
|
|
152
|
+
priors.observe(obs)
|
|
153
|
+
store.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_report(args: argparse.Namespace) -> None:
|
|
157
|
+
"""Execute the ``report`` subcommand: show learned priors."""
|
|
158
|
+
store = _get_store(args.db)
|
|
159
|
+
priors = SQLiteBackedPriors(store)
|
|
160
|
+
reports = priors.report_all()
|
|
161
|
+
|
|
162
|
+
if not reports:
|
|
163
|
+
print("No observations recorded yet.")
|
|
164
|
+
else:
|
|
165
|
+
for r in reports:
|
|
166
|
+
print(f"\n=== {r['task_type']} / {r['model_id']} ===")
|
|
167
|
+
print(f" Calls: {r['total_calls']}")
|
|
168
|
+
print(f" Optimal depth: {r['optimal_depth']}")
|
|
169
|
+
print(f" Converge rate: {r['converge_rate']:.1%}")
|
|
170
|
+
print(f" First-call quality: {r['first_call_quality']:.3f}")
|
|
171
|
+
print(f" Confidence: {r['confidence']:.3f}")
|
|
172
|
+
if r.get("iterations"):
|
|
173
|
+
print(" Iterations:")
|
|
174
|
+
for k, v in r["iterations"].items():
|
|
175
|
+
print(
|
|
176
|
+
f" {k}: score={v['expected_score']:.3f} "
|
|
177
|
+
f"delta={v['expected_delta']:.3f} "
|
|
178
|
+
f"converge={v['converge_prob']:.1%}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Also show question stats
|
|
182
|
+
stats = store.get_question_stats()
|
|
183
|
+
if stats:
|
|
184
|
+
print("\n=== Question Effectiveness ===")
|
|
185
|
+
for s in stats:
|
|
186
|
+
print(
|
|
187
|
+
f" {s['question_type']:15s} "
|
|
188
|
+
f"asked={s['asked_count']:3d} "
|
|
189
|
+
f"effectiveness={s['effectiveness']:.1%} "
|
|
190
|
+
f"info_gain={s['avg_info_gain']:.3f}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
store.close()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
197
|
+
"""Execute the ``run`` subcommand: full pipeline with task orchestration."""
|
|
198
|
+
provider = _get_provider(args.provider)
|
|
199
|
+
store = _get_store(args.db)
|
|
200
|
+
priors = SQLiteBackedPriors(store)
|
|
201
|
+
|
|
202
|
+
orchestrator = TaskOrchestrator(
|
|
203
|
+
provider=provider,
|
|
204
|
+
priors=priors,
|
|
205
|
+
store=store,
|
|
206
|
+
model=args.model,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
answer_func = None if args.no_questions else _interactive_answer
|
|
210
|
+
|
|
211
|
+
print(f"Running full pipeline: {args.prompt[:80]}...")
|
|
212
|
+
result = orchestrator.run(
|
|
213
|
+
args.prompt,
|
|
214
|
+
model=args.model,
|
|
215
|
+
answer_func=answer_func,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
print("\n--- Result ---")
|
|
219
|
+
print(f"Exit: {result.metrics.exit_reason.condition}")
|
|
220
|
+
print(f"Iterations: {result.metrics.total_iterations}")
|
|
221
|
+
print(f"Best score: {result.metrics.best_score:.3f}")
|
|
222
|
+
print(f"Output:\n{result.output}")
|
|
223
|
+
store.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_score(args: argparse.Namespace) -> None:
|
|
227
|
+
"""Score a prompt and update ~/.loopllm/status.json for the VS Code extension.
|
|
228
|
+
|
|
229
|
+
This is the same scoring used by loopllm_intercept but runs standalone,
|
|
230
|
+
with no MCP server or agent required. The extension watches status.json
|
|
231
|
+
via fs.watch and updates the gauge and dashboard immediately.
|
|
232
|
+
"""
|
|
233
|
+
import time
|
|
234
|
+
from pathlib import Path
|
|
235
|
+
|
|
236
|
+
# Import scorer — mcp import is guarded so this is safe even without mcp pkg
|
|
237
|
+
from loopllm.mcp_server import (
|
|
238
|
+
_score_prompt_quality,
|
|
239
|
+
_classify_task_type,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Read prompt: argument, or "-" / empty arg means stdin
|
|
243
|
+
raw = args.prompt
|
|
244
|
+
if not raw or raw == "-":
|
|
245
|
+
raw = sys.stdin.read()
|
|
246
|
+
prompt = raw.strip()
|
|
247
|
+
if not prompt:
|
|
248
|
+
print("Error: empty prompt", file=sys.stderr)
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
|
|
251
|
+
quality = _score_prompt_quality(prompt)
|
|
252
|
+
task_type = _classify_task_type(prompt)
|
|
253
|
+
|
|
254
|
+
db_path = Path(args.db) if args.db else Path.home() / ".loopllm" / "store.db"
|
|
255
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
status_path = db_path.parent / "status.json"
|
|
257
|
+
history_path = db_path.parent / "prompt_history.json"
|
|
258
|
+
|
|
259
|
+
payload = {
|
|
260
|
+
"quality_score": quality["quality_score"],
|
|
261
|
+
"grade": quality["grade"],
|
|
262
|
+
"gauge": quality["gauge"],
|
|
263
|
+
"task_type": task_type,
|
|
264
|
+
"route": "score",
|
|
265
|
+
"dimensions": quality["dimensions"],
|
|
266
|
+
"suggestions": quality.get("suggestions", []),
|
|
267
|
+
"issues": quality.get("issues", []),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
# Write status.json — picked up by StatusWatcher in the extension
|
|
271
|
+
status_path.write_text(json.dumps({
|
|
272
|
+
"timestamp": time.time(),
|
|
273
|
+
"tool": "score",
|
|
274
|
+
"data": payload,
|
|
275
|
+
}, indent=2))
|
|
276
|
+
|
|
277
|
+
# Append to prompt_history.json — picked up by DataProvider poll
|
|
278
|
+
try:
|
|
279
|
+
history: list[dict[str, Any]] = []
|
|
280
|
+
if history_path.exists():
|
|
281
|
+
try:
|
|
282
|
+
history = json.loads(history_path.read_text())
|
|
283
|
+
if not isinstance(history, list):
|
|
284
|
+
history = []
|
|
285
|
+
except (json.JSONDecodeError, OSError):
|
|
286
|
+
history = []
|
|
287
|
+
history.append({
|
|
288
|
+
"id": len(history) + 1,
|
|
289
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
290
|
+
"prompt_text": prompt[:500],
|
|
291
|
+
**payload,
|
|
292
|
+
})
|
|
293
|
+
history_path.write_text(json.dumps(history, indent=2))
|
|
294
|
+
except OSError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
if getattr(args, "json", False):
|
|
298
|
+
print(json.dumps(payload, indent=2))
|
|
299
|
+
else:
|
|
300
|
+
print(f"{quality['gauge']}")
|
|
301
|
+
if quality.get("suggestions"):
|
|
302
|
+
for s in quality["suggestions"][:3]:
|
|
303
|
+
print(f" · {s}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_tasks_list(args: argparse.Namespace) -> None:
|
|
307
|
+
"""List tasks from the store."""
|
|
308
|
+
store = _get_store(args.db)
|
|
309
|
+
tasks = store.get_tasks(state=args.state, limit=args.limit)
|
|
310
|
+
|
|
311
|
+
if not tasks:
|
|
312
|
+
print("No tasks found.")
|
|
313
|
+
else:
|
|
314
|
+
for t in tasks:
|
|
315
|
+
print(
|
|
316
|
+
f" [{t['state']:12s}] {t['id'][:8]} {t['title']}"
|
|
317
|
+
)
|
|
318
|
+
store.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_tasks_show(args: argparse.Namespace) -> None:
|
|
322
|
+
"""Show details for a specific task."""
|
|
323
|
+
store = _get_store(args.db)
|
|
324
|
+
task = store.get_task(args.task_id)
|
|
325
|
+
|
|
326
|
+
if task is None:
|
|
327
|
+
print(f"Task not found: {args.task_id}")
|
|
328
|
+
else:
|
|
329
|
+
print(json.dumps(task, indent=2, default=str))
|
|
330
|
+
store.close()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
334
|
+
"""Build the argument parser for the ``loopllm`` CLI."""
|
|
335
|
+
parser = argparse.ArgumentParser(
|
|
336
|
+
prog="loopllm",
|
|
337
|
+
description="Iterative refinement engine with Bayesian intent elicitation.",
|
|
338
|
+
)
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"--db", default=None,
|
|
341
|
+
help="Path to SQLite database (default: ~/.loopllm/store.db)",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
345
|
+
|
|
346
|
+
# --- score ---
|
|
347
|
+
p_score = subparsers.add_parser(
|
|
348
|
+
"score",
|
|
349
|
+
help="Score a prompt and update the VS Code gauge instantly (no MCP server needed)",
|
|
350
|
+
)
|
|
351
|
+
p_score.add_argument(
|
|
352
|
+
"prompt",
|
|
353
|
+
nargs="?",
|
|
354
|
+
default="-",
|
|
355
|
+
help="Prompt text to score, or '-' to read from stdin (default: stdin)",
|
|
356
|
+
)
|
|
357
|
+
p_score.add_argument(
|
|
358
|
+
"--json",
|
|
359
|
+
action="store_true",
|
|
360
|
+
help="Output full JSON result instead of gauge bar",
|
|
361
|
+
)
|
|
362
|
+
p_score.set_defaults(func=cmd_score)
|
|
363
|
+
|
|
364
|
+
# --- refine ---
|
|
365
|
+
p_refine = subparsers.add_parser(
|
|
366
|
+
"refine", help="Elicit intent and refine a prompt"
|
|
367
|
+
)
|
|
368
|
+
p_refine.add_argument("prompt", help="The prompt to refine")
|
|
369
|
+
p_refine.add_argument(
|
|
370
|
+
"--provider", default="mock",
|
|
371
|
+
choices=["mock", "ollama", "openrouter"],
|
|
372
|
+
help="LLM provider (default: mock)",
|
|
373
|
+
)
|
|
374
|
+
p_refine.add_argument("--model", default="gpt-4o-mini", help="Model to use")
|
|
375
|
+
p_refine.add_argument(
|
|
376
|
+
"--no-questions", action="store_true",
|
|
377
|
+
help="Skip intent elicitation",
|
|
378
|
+
)
|
|
379
|
+
p_refine.add_argument(
|
|
380
|
+
"--max-questions", type=int, default=3,
|
|
381
|
+
help="Maximum clarifying questions (default: 3)",
|
|
382
|
+
)
|
|
383
|
+
p_refine.add_argument(
|
|
384
|
+
"--max-iterations", type=int, default=5,
|
|
385
|
+
help="Maximum refinement iterations (default: 5)",
|
|
386
|
+
)
|
|
387
|
+
p_refine.add_argument(
|
|
388
|
+
"--threshold", type=float, default=0.8,
|
|
389
|
+
help="Quality threshold (default: 0.8)",
|
|
390
|
+
)
|
|
391
|
+
p_refine.set_defaults(func=cmd_refine)
|
|
392
|
+
|
|
393
|
+
# --- run ---
|
|
394
|
+
p_run = subparsers.add_parser(
|
|
395
|
+
"run", help="Full pipeline: elicit → decompose → execute → verify"
|
|
396
|
+
)
|
|
397
|
+
p_run.add_argument("prompt", help="The prompt to process")
|
|
398
|
+
p_run.add_argument(
|
|
399
|
+
"--provider", default="mock",
|
|
400
|
+
choices=["mock", "ollama", "openrouter"],
|
|
401
|
+
)
|
|
402
|
+
p_run.add_argument("--model", default="gpt-4o-mini")
|
|
403
|
+
p_run.add_argument("--no-questions", action="store_true")
|
|
404
|
+
p_run.add_argument("--max-questions", type=int, default=3)
|
|
405
|
+
p_run.add_argument("--max-iterations", type=int, default=5)
|
|
406
|
+
p_run.add_argument("--threshold", type=float, default=0.8)
|
|
407
|
+
p_run.set_defaults(func=cmd_run)
|
|
408
|
+
|
|
409
|
+
# --- report ---
|
|
410
|
+
p_report = subparsers.add_parser(
|
|
411
|
+
"report", help="Show learned priors and statistics"
|
|
412
|
+
)
|
|
413
|
+
p_report.set_defaults(func=cmd_report)
|
|
414
|
+
|
|
415
|
+
# --- tasks ---
|
|
416
|
+
p_tasks = subparsers.add_parser("tasks", help="Task management")
|
|
417
|
+
tasks_sub = p_tasks.add_subparsers(dest="tasks_command")
|
|
418
|
+
|
|
419
|
+
p_tlist = tasks_sub.add_parser("list", help="List tasks")
|
|
420
|
+
p_tlist.add_argument("--state", default=None, help="Filter by state")
|
|
421
|
+
p_tlist.add_argument("--limit", type=int, default=20)
|
|
422
|
+
p_tlist.set_defaults(func=cmd_tasks_list)
|
|
423
|
+
|
|
424
|
+
p_tshow = tasks_sub.add_parser("show", help="Show task details")
|
|
425
|
+
p_tshow.add_argument("task_id", help="Task ID")
|
|
426
|
+
p_tshow.set_defaults(func=cmd_tasks_show)
|
|
427
|
+
|
|
428
|
+
# --- mcp-server ---
|
|
429
|
+
p_mcp = subparsers.add_parser(
|
|
430
|
+
"mcp-server", help="Start MCP server for IDE integration (VS Code, Cursor)"
|
|
431
|
+
)
|
|
432
|
+
p_mcp.add_argument(
|
|
433
|
+
"--provider", default=None,
|
|
434
|
+
choices=["agent", "mock", "ollama", "openrouter"],
|
|
435
|
+
help="LLM provider (default: LOOPLLM_PROVIDER env or agent)",
|
|
436
|
+
)
|
|
437
|
+
p_mcp.add_argument(
|
|
438
|
+
"--model", default=None,
|
|
439
|
+
help="Default model (default: LOOPLLM_MODEL env or gpt-4o-mini)",
|
|
440
|
+
)
|
|
441
|
+
p_mcp.add_argument(
|
|
442
|
+
"--db", default=None,
|
|
443
|
+
help="Path to SQLite database (default: ~/.loopllm/store.db)",
|
|
444
|
+
)
|
|
445
|
+
p_mcp.set_defaults(func=cmd_mcp_server)
|
|
446
|
+
|
|
447
|
+
# --- serve ---
|
|
448
|
+
p_serve = subparsers.add_parser(
|
|
449
|
+
"serve",
|
|
450
|
+
help="Start REST scoring server for local models (Ollama, llama.cpp, etc.)",
|
|
451
|
+
)
|
|
452
|
+
p_serve.add_argument(
|
|
453
|
+
"--host", default="127.0.0.1",
|
|
454
|
+
help="Bind address (default: 127.0.0.1)",
|
|
455
|
+
)
|
|
456
|
+
p_serve.add_argument(
|
|
457
|
+
"--port", type=int, default=8765,
|
|
458
|
+
help="Port to listen on (default: 8765)",
|
|
459
|
+
)
|
|
460
|
+
p_serve.add_argument(
|
|
461
|
+
"--reload", action="store_true",
|
|
462
|
+
help="Enable auto-reload (development only)",
|
|
463
|
+
)
|
|
464
|
+
p_serve.set_defaults(func=cmd_serve)
|
|
465
|
+
|
|
466
|
+
return parser
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
470
|
+
"""Start the loopllm scoring REST server."""
|
|
471
|
+
try:
|
|
472
|
+
from loopllm.serve import run_server
|
|
473
|
+
except ImportError:
|
|
474
|
+
print(
|
|
475
|
+
"Error: FastAPI and uvicorn are required for `loopllm serve`.\n"
|
|
476
|
+
"Install with: pip install loopllm[serve]",
|
|
477
|
+
file=sys.stderr,
|
|
478
|
+
)
|
|
479
|
+
sys.exit(1)
|
|
480
|
+
run_server(host=args.host, port=args.port, reload=args.reload)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_mcp_server(args: argparse.Namespace) -> None:
|
|
484
|
+
"""Start the MCP server for IDE integration."""
|
|
485
|
+
import os
|
|
486
|
+
|
|
487
|
+
# Pass CLI args as env vars so mcp_server.py picks them up
|
|
488
|
+
if args.provider:
|
|
489
|
+
os.environ["LOOPLLM_PROVIDER"] = args.provider
|
|
490
|
+
if args.model:
|
|
491
|
+
os.environ["LOOPLLM_MODEL"] = args.model
|
|
492
|
+
if args.db:
|
|
493
|
+
os.environ["LOOPLLM_DB"] = args.db
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
from loopllm.mcp_server import main as mcp_main
|
|
497
|
+
except ImportError:
|
|
498
|
+
print(
|
|
499
|
+
"Error: The mcp package is required for the MCP server.\n"
|
|
500
|
+
"Install it with: pip install loopllm[mcp]",
|
|
501
|
+
file=sys.stderr,
|
|
502
|
+
)
|
|
503
|
+
sys.exit(1)
|
|
504
|
+
|
|
505
|
+
mcp_main()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def main() -> None:
|
|
509
|
+
"""Entry point for the ``loopllm`` CLI."""
|
|
510
|
+
parser = build_parser()
|
|
511
|
+
args = parser.parse_args()
|
|
512
|
+
|
|
513
|
+
if not hasattr(args, "func"):
|
|
514
|
+
parser.print_help()
|
|
515
|
+
sys.exit(1)
|
|
516
|
+
|
|
517
|
+
args.func(args)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
if __name__ == "__main__":
|
|
521
|
+
main()
|