lcode-cli 0.3.0__py3-none-any.whl → 0.3.1__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.
lcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
lcode/agent.py CHANGED
@@ -154,6 +154,7 @@ class Agent:
154
154
  self.session_title = ""
155
155
  self.ctx_used = 0
156
156
  self.last_speed = 0.0
157
+ self.usage = {"requests": 0, "prompt_tokens": 0, "prompt_ns": 0, "output_tokens": 0, "output_ns": 0}
157
158
  self.messages: list[dict] = []
158
159
  self.reset()
159
160
 
@@ -390,6 +391,11 @@ class Agent:
390
391
  message["tool_calls"] = tool_calls
391
392
  self.messages.append(message)
392
393
  if final:
394
+ self.usage["requests"] += 1
395
+ self.usage["prompt_tokens"] += final.get("prompt_eval_count", 0)
396
+ self.usage["prompt_ns"] += final.get("prompt_eval_duration", 0)
397
+ self.usage["output_tokens"] += final.get("eval_count", 0)
398
+ self.usage["output_ns"] += final.get("eval_duration", 0)
393
399
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
394
400
  seconds = final.get("eval_duration", 0) / 1e9
395
401
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
lcode/bench.py ADDED
@@ -0,0 +1,1082 @@
1
+ """lcode bench: score models on small, self-contained coding tasks on this machine.
2
+
3
+ Each task runs in a fresh temporary folder in `yolo` mode without web access, and ends with an
4
+ automatic check of the files or the answer. Some checks rerun the model's code on data it never
5
+ saw, so hard-coding the expected output doesn't pass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import io
12
+ import json
13
+ import random
14
+ import re
15
+ import shutil
16
+ import signal
17
+ import statistics
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ from collections.abc import Callable, Iterator
23
+ from contextlib import contextmanager
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+
27
+ from rich.console import Console
28
+ from rich.markup import escape
29
+ from rich.table import Table
30
+ from rich.text import Text
31
+
32
+ from lcode import __version__
33
+ from lcode.agent import Agent, Settings
34
+ from lcode.config import format_tokens
35
+ from lcode.hardware import Hardware
36
+ from lcode.ollama import Ollama, OllamaError
37
+
38
+ SCHEMA_VERSION = 1
39
+ DEFAULT_CONTEXT = 32768 # the same everywhere, so results compare across machines
40
+ DEFAULT_TIMEOUT = 300 # seconds per task
41
+ CHECK_TIMEOUT = 60
42
+ PROBE_WORDS = 3000 # about 4,000 tokens
43
+ PROBE_TEXT = (
44
+ "file function value error test module class return import list string number change build user "
45
+ "server request cache memory thread order parse check config option result folder branch commit"
46
+ )
47
+
48
+
49
+ class TaskTimeout(KeyboardInterrupt):
50
+ """Raised when a task runs out of time. The agent stops running commands just like on Ctrl+C."""
51
+
52
+
53
+ @dataclass
54
+ class Check:
55
+ passed: bool
56
+ detail: str
57
+
58
+
59
+ @dataclass
60
+ class Task:
61
+ id: str
62
+ title: str
63
+ prompt: str
64
+ files: dict[str, str]
65
+ check: Callable[[Path, str], Check] # (task folder, the model's final answer) -> result
66
+
67
+
68
+ # ----------------------------------------------------------------------------- helpers for checks
69
+
70
+
71
+ def run_python(folder: Path, *args: str) -> subprocess.CompletedProcess:
72
+ return subprocess.run(
73
+ [sys.executable, *args],
74
+ cwd=folder,
75
+ capture_output=True,
76
+ text=True,
77
+ stdin=subprocess.DEVNULL,
78
+ timeout=CHECK_TIMEOUT,
79
+ )
80
+
81
+
82
+ def read(folder: Path, name: str) -> str | None:
83
+ try:
84
+ return (folder / name).read_text()
85
+ except (OSError, UnicodeDecodeError):
86
+ return None
87
+
88
+
89
+ def last_line(text: str) -> str:
90
+ lines = [ln for ln in text.strip().splitlines() if ln.strip()]
91
+ return lines[-1][:200] if lines else ""
92
+
93
+
94
+ def unittest_check(folder: Path, what: str) -> Check:
95
+ r = run_python(folder, "-m", "unittest", "-q")
96
+ if r.returncode == 0:
97
+ return Check(True, f"{what} pass")
98
+ return Check(False, f"{what} fail: {last_line(r.stderr) or last_line(r.stdout)}")
99
+
100
+
101
+ # ----------------------------------------------------------------------------- 1. fix a bug
102
+
103
+ CART = '''\
104
+ """A tiny shopping cart."""
105
+
106
+
107
+ class Cart:
108
+ def __init__(self):
109
+ self.items = []
110
+
111
+ def add(self, name, price, quantity=1):
112
+ self.items.append((name, price, quantity))
113
+
114
+ def total(self, discount=0.0):
115
+ """Total price after an optional discount between 0 and 1."""
116
+ subtotal = sum(price for _, price, quantity in self.items)
117
+ return round(subtotal * (1 - discount), 2)
118
+ '''
119
+
120
+ CART_TESTS = """\
121
+ import unittest
122
+
123
+ from cart import Cart
124
+
125
+
126
+ class CartTest(unittest.TestCase):
127
+ def test_total_counts_quantities(self):
128
+ cart = Cart()
129
+ cart.add("apple", 0.5, quantity=4)
130
+ cart.add("bread", 2.25)
131
+ self.assertEqual(cart.total(), 4.25)
132
+
133
+ def test_discount(self):
134
+ cart = Cart()
135
+ cart.add("coffee", 10.0, quantity=2)
136
+ self.assertEqual(cart.total(discount=0.1), 18.0)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()
141
+ """
142
+
143
+
144
+ def check_fix_bug(folder: Path, answer: str) -> Check:
145
+ if read(folder, "test_cart.py") != CART_TESTS:
146
+ return Check(False, "test_cart.py was changed; the fix belongs in cart.py")
147
+ return unittest_check(folder, "the tests")
148
+
149
+
150
+ # ----------------------------------------------------------------------------- 2. find code
151
+
152
+ UPLOADER = {
153
+ "uploader/__init__.py": '"""Upload files to the storage service."""\n',
154
+ "uploader/config.py": '''\
155
+ """Settings for the uploader."""
156
+
157
+ BASE_DELAY = 0.5 # seconds
158
+ MAX_DELAY = 30.0
159
+ MAX_ATTEMPTS = 6
160
+ POLL_INTERVAL = 2.0
161
+ CHUNK_SIZE = 4 * 1024 * 1024
162
+ ''',
163
+ "uploader/client.py": '''\
164
+ """Talking to the storage service."""
165
+
166
+ import time
167
+
168
+ from uploader.config import POLL_INTERVAL
169
+
170
+
171
+ class StorageClient:
172
+ def __init__(self, session):
173
+ self.session = session
174
+
175
+ def put_chunk(self, url, data):
176
+ response = self.session.put(url, data=data)
177
+ response.raise_for_status()
178
+ return response
179
+
180
+ def wait_until_ready(self, job_id):
181
+ """Poll the service until a processing job has finished."""
182
+ while self.session.get(f"/jobs/{job_id}").json()["state"] != "done":
183
+ time.sleep(POLL_INTERVAL)
184
+ ''',
185
+ "uploader/backoff.py": '''\
186
+ """Spacing out attempts."""
187
+
188
+ import random
189
+
190
+ from uploader.config import BASE_DELAY, MAX_DELAY
191
+
192
+
193
+ def jitter(delay):
194
+ """Spread attempts out so clients don't all hit the service at once."""
195
+ return delay * random.uniform(0.8, 1.2)
196
+
197
+
198
+ def next_wait(attempt):
199
+ """Seconds to pause after `attempt` failed tries of the same chunk."""
200
+ delay = min(MAX_DELAY, BASE_DELAY * 2**attempt)
201
+ return jitter(delay)
202
+ ''',
203
+ "uploader/transfer.py": '''\
204
+ """Uploading a file in chunks."""
205
+
206
+ import time
207
+
208
+ from uploader.backoff import next_wait
209
+ from uploader.config import CHUNK_SIZE, MAX_ATTEMPTS
210
+
211
+
212
+ def chunks(path):
213
+ with open(path, "rb") as f:
214
+ while block := f.read(CHUNK_SIZE):
215
+ yield block
216
+
217
+
218
+ def upload(client, url, path):
219
+ for index, block in enumerate(chunks(path)):
220
+ for attempt in range(MAX_ATTEMPTS):
221
+ try:
222
+ client.put_chunk(f"{url}?part={index}", block)
223
+ break
224
+ except OSError:
225
+ time.sleep(next_wait(attempt))
226
+ else:
227
+ raise RuntimeError(f"chunk {index} failed {MAX_ATTEMPTS} times")
228
+ ''',
229
+ }
230
+
231
+
232
+ def check_find_code(folder: Path, answer: str) -> Check:
233
+ lines = UPLOADER["uploader/backoff.py"].splitlines()
234
+ start = next(i for i, line in enumerate(lines, 1) if line.startswith("def next_wait"))
235
+ end = start + 3 # def, docstring and two lines of body
236
+ cited = [int(n) for n in re.findall(r"backoff\.py:(\d+)", answer)]
237
+ if "next_wait" not in answer:
238
+ named = re.findall(r"`([A-Za-z_]\w*)(?:\(\))?`", answer)
239
+ return Check(False, f"named {named[0]} instead of next_wait" if named else "didn't name next_wait")
240
+ if not cited:
241
+ return Check(False, "named next_wait but didn't cite backoff.py:<line>")
242
+ if not any(start <= n <= end for n in cited):
243
+ return Check(False, f"cited backoff.py:{cited[0]}, but next_wait is at lines {start}-{end}")
244
+ return Check(True, f"next_wait at backoff.py:{cited[0]}")
245
+
246
+
247
+ # ----------------------------------------------------------------------------- 3. write a script
248
+
249
+ NUMBERS = "12\n7\n\n3\n25\n8\n19\n"
250
+ HIDDEN_NUMBERS = "5\n-2\n\n10\n4\n4\n" # checked with data the model never saw
251
+
252
+
253
+ def expected_stats(data: str) -> list[str]:
254
+ numbers = [int(line) for line in data.splitlines() if line.strip()]
255
+ return [
256
+ f"count: {len(numbers)}",
257
+ f"sum: {sum(numbers)}",
258
+ f"mean: {statistics.mean(numbers):.2f}",
259
+ f"median: {statistics.median(numbers):.2f}",
260
+ ]
261
+
262
+
263
+ def check_write_script(folder: Path, answer: str) -> Check:
264
+ if not (folder / "stats.py").is_file():
265
+ return Check(False, "stats.py wasn't created")
266
+ for label, data in (("numbers.txt", NUMBERS), ("other data", HIDDEN_NUMBERS)):
267
+ (folder / "numbers.txt").write_text(data)
268
+ r = run_python(folder, "stats.py")
269
+ got = [line.strip() for line in r.stdout.strip().splitlines()]
270
+ want = expected_stats(data)
271
+ if r.returncode != 0:
272
+ return Check(False, f"stats.py failed on {label}: {last_line(r.stderr)}")
273
+ if got != want:
274
+ return Check(False, f"wrong output on {label}: {' | '.join(got)[:120]} (expected {' | '.join(want)})")
275
+ return Check(True, "correct output, also on data it hadn't seen")
276
+
277
+
278
+ # ----------------------------------------------------------------------------- 4. rename across files
279
+
280
+ SHOP = {
281
+ "shop/__init__.py": "",
282
+ "shop/users.py": '''\
283
+ """Users of the shop."""
284
+
285
+
286
+ def get_user_name(user):
287
+ """The name to show for a user: their nickname if they have one."""
288
+ return user.get("nickname") or f"{user['first']} {user['last']}"
289
+ ''',
290
+ "shop/orders.py": """\
291
+ from shop.users import get_user_name
292
+
293
+
294
+ def order_summary(order):
295
+ return f"Order {order['id']} for {get_user_name(order['user'])}: {len(order['items'])} item(s)"
296
+ """,
297
+ "shop/emails.py": """\
298
+ from shop import users
299
+
300
+
301
+ def greeting(user):
302
+ return f"Hello {users.get_user_name(user)},"
303
+ """,
304
+ "test_shop.py": """\
305
+ import unittest
306
+
307
+ from shop.emails import greeting
308
+ from shop.orders import order_summary
309
+ from shop.users import get_user_name
310
+
311
+ ADA = {"first": "Ada", "last": "Lovelace"}
312
+ BOB = {"first": "Robert", "last": "Smith", "nickname": "Bob"}
313
+
314
+
315
+ class ShopTest(unittest.TestCase):
316
+ def test_name(self):
317
+ self.assertEqual(get_user_name(ADA), "Ada Lovelace")
318
+ self.assertEqual(get_user_name(BOB), "Bob")
319
+
320
+ def test_callers(self):
321
+ self.assertEqual(greeting(BOB), "Hello Bob,")
322
+ self.assertEqual(order_summary({"id": 7, "user": ADA, "items": [1, 2]}), "Order 7 for Ada Lovelace: 2 item(s)")
323
+
324
+
325
+ if __name__ == "__main__":
326
+ unittest.main()
327
+ """,
328
+ }
329
+
330
+ HIDDEN_SHOP_TEST = """\
331
+ import unittest
332
+
333
+ from shop import users
334
+ from shop.emails import greeting
335
+ from shop.orders import order_summary
336
+
337
+ ADA = {"first": "Ada", "last": "Lovelace"}
338
+ BOB = {"first": "Robert", "last": "Smith", "nickname": "Bob"}
339
+
340
+
341
+ class HiddenTest(unittest.TestCase):
342
+ def test_renamed(self):
343
+ self.assertFalse(hasattr(users, "get_user_name"))
344
+ self.assertEqual(users.display_name(ADA), "Ada Lovelace")
345
+ self.assertEqual(users.display_name(BOB), "Bob")
346
+ self.assertEqual(greeting(ADA), "Hello Ada Lovelace,")
347
+ self.assertEqual(order_summary({"id": 1, "user": BOB, "items": []}), "Order 1 for Bob: 0 item(s)")
348
+ """
349
+
350
+
351
+ def check_rename(folder: Path, answer: str) -> Check:
352
+ left = sorted(
353
+ str(p.relative_to(folder))
354
+ for p in folder.rglob("*.py")
355
+ if "__pycache__" not in p.parts and "get_user_name" in (read(folder, str(p.relative_to(folder))) or "")
356
+ )
357
+ if left:
358
+ return Check(False, f"get_user_name is still used in {', '.join(left)}")
359
+ tests = folder / "test_shop.py"
360
+ if not tests.is_file() or "display_name" not in tests.read_text():
361
+ return Check(False, "test_shop.py wasn't updated")
362
+ (folder / "test_hidden_rename.py").write_text(HIDDEN_SHOP_TEST)
363
+ return unittest_check(folder, "its tests and extra checks")
364
+
365
+
366
+ # ----------------------------------------------------------------------------- 5. precise edit in a long file
367
+
368
+
369
+ def services_file() -> str:
370
+ lines = ['"""Connection settings for internal services."""', "", "SERVICES = {"]
371
+ for i in range(1, 151):
372
+ names = [f"service_{i:03d}"] + ([f"service_{i:03d}_replica"] if i % 29 == 0 else [])
373
+ for name in names:
374
+ lines += [
375
+ f' "{name}": {{',
376
+ f' "host": "{name.replace("_", "-")}.internal",',
377
+ f' "port": {8000 + i},',
378
+ f' "timeout": {60 if i % 10 == 0 else 30},',
379
+ f' "retries": {5 if i % 7 == 0 else 3},',
380
+ " },",
381
+ ]
382
+ lines += ["}", ""]
383
+ return "\n".join(lines)
384
+
385
+
386
+ SERVICES = services_file()
387
+ TARGET_SERVICE = "service_087"
388
+
389
+
390
+ def check_precise_edit(folder: Path, answer: str) -> Check:
391
+ new = read(folder, "services.py")
392
+ if new is None:
393
+ return Check(False, "services.py is missing")
394
+ before, after = SERVICES.splitlines(), new.splitlines()
395
+ target = before.index(f' "{TARGET_SERVICE}": {{') + 3 # its timeout line
396
+ expected = before.copy()
397
+ expected[target] = expected[target].replace("30", "45")
398
+ if after == expected:
399
+ return Check(True, f"changed only {TARGET_SERVICE}'s timeout")
400
+ if len(after) != len(before):
401
+ return Check(False, f"the file went from {len(before)} to {len(after)} lines")
402
+ changed = [i for i, (a, b) in enumerate(zip(before, after, strict=True)) if a != b]
403
+ if target not in changed:
404
+ return Check(False, f"{TARGET_SERVICE}'s timeout wasn't changed to 45")
405
+ return Check(False, f"changed {len(changed)} lines instead of 1")
406
+
407
+
408
+ # ----------------------------------------------------------------------------- 6. recover from errors
409
+
410
+ TOOL = '''\
411
+ """Build a ranked report from a data file.
412
+
413
+ Usage: python tool.py --source DATA --out REPORT
414
+ """
415
+
416
+ import argparse
417
+ import sys
418
+
419
+
420
+ def main():
421
+ parser = argparse.ArgumentParser(description="Build a ranked report from a data file.")
422
+ parser.add_argument("--source", help="data file with one 'name,score' per line")
423
+ parser.add_argument("--out", help="where to write the report")
424
+ parser.add_argument("--input", help=argparse.SUPPRESS)
425
+ args = parser.parse_args()
426
+ if args.input:
427
+ sys.exit("error: --input was removed in version 2; use --source instead")
428
+ if not args.source or not args.out:
429
+ parser.error("--source and --out are required")
430
+ rows = []
431
+ with open(args.source) as f:
432
+ for number, line in enumerate(f, 1):
433
+ if not line.strip():
434
+ continue
435
+ name, _, score = line.partition(",")
436
+ if not score.strip().isdigit():
437
+ sys.exit(f"error: {args.source} line {number}: score must be a whole number, got {score.strip()!r}")
438
+ rows.append((name.strip(), int(score)))
439
+ rows.sort(key=lambda row: (-row[1], row[0]))
440
+ with open(args.out, "w") as f:
441
+ for rank, (name, score) in enumerate(rows, 1):
442
+ f.write(f"{rank}. {name} {score}\\n")
443
+ f.write(f"total {sum(score for _, score in rows)}\\n")
444
+
445
+
446
+ if __name__ == "__main__":
447
+ main()
448
+ '''
449
+
450
+ SCORES = "alice, 42\nbob, 17\ncarol, 42\ndave, n/a\nerin, 8\n"
451
+ GOOD_ROWS = [("alice", 42), ("carol", 42), ("bob", 17), ("erin", 8)]
452
+
453
+
454
+ def check_recover(folder: Path, answer: str) -> Check:
455
+ report = read(folder, "report.txt")
456
+ expected = "".join(f"{rank}. {name} {score}\n" for rank, (name, score) in enumerate(GOOD_ROWS, 1))
457
+ expected += f"total {sum(score for _, score in GOOD_ROWS)}\n"
458
+ if report is None:
459
+ return Check(False, "report.txt wasn't created")
460
+ data = read(folder, "data.txt") or ""
461
+ if "dave" in data or any(name not in data for name, _ in GOOD_ROWS):
462
+ return Check(False, "data.txt should lose only the invalid line")
463
+ if report != expected:
464
+ return Check(False, f"report.txt is wrong: {' | '.join(report.splitlines())[:120]}")
465
+ return Check(True, "used --source and dropped the invalid line")
466
+
467
+
468
+ # ----------------------------------------------------------------------------- 7. fix to a written spec
469
+
470
+ DURATIONS = '''\
471
+ import re
472
+
473
+ UNITS = {"h": 3600, "m": 60, "s": 1}
474
+
475
+
476
+ def parse_duration(text):
477
+ """Parse a duration such as "1h30m", "45s" or "2h" into seconds.
478
+
479
+ - The units are h, m and s, in that order, each at most once: "1h0m5s" is fine, "30m1h" is not.
480
+ - Units are case-insensitive, and spaces between the parts are allowed: "1H 30m".
481
+ - A plain number means seconds: "90".
482
+ - Anything else raises ValueError, including an empty string and unknown units like "1x".
483
+ """
484
+ total = 0
485
+ for number, unit in re.findall(r"(\\d+)([hms])", text):
486
+ total = int(number) * UNITS[unit]
487
+ return total
488
+ '''
489
+
490
+ HIDDEN_DURATION_TEST = """\
491
+ import unittest
492
+
493
+ from durations import parse_duration
494
+
495
+
496
+ class HiddenTest(unittest.TestCase):
497
+ def test_valid(self):
498
+ cases = {"1h30m": 5400, "45s": 45, "2h": 7200, "1h0m5s": 3605, "90m": 5400, "1H 30m": 5400,
499
+ "90": 90, "0s": 0, "2h 5s": 7205, "1h 2m 3s": 3723}
500
+ for text, seconds in cases.items():
501
+ with self.subTest(text=text):
502
+ self.assertEqual(parse_duration(text), seconds)
503
+
504
+ def test_invalid(self):
505
+ for text in ["", "1x", "30m1h", "h", "1h1h", "abc", "5m 3m"]:
506
+ with self.subTest(text=text):
507
+ with self.assertRaises(ValueError):
508
+ parse_duration(text)
509
+ """
510
+
511
+
512
+ def check_spec_fix(folder: Path, answer: str) -> Check:
513
+ if not (folder / "durations.py").is_file():
514
+ return Check(False, "durations.py is missing")
515
+ (folder / "test_hidden_durations.py").write_text(HIDDEN_DURATION_TEST)
516
+ r = run_python(folder, "-m", "unittest", "-q", "test_hidden_durations")
517
+ if r.returncode == 0:
518
+ return Check(True, "follows the whole docstring")
519
+ failed = sorted(set(re.findall(r"\(text='([^']*)'\)", r.stderr)))
520
+ if failed:
521
+ return Check(False, "wrong for " + ", ".join(repr(t) for t in failed[:6]))
522
+ return Check(False, f"hidden tests fail: {last_line(r.stderr)}")
523
+
524
+
525
+ # ----------------------------------------------------------------------------- 8. a feature across files
526
+
527
+ TODO_APP = {
528
+ "storage.py": '''\
529
+ """Todos live in todos.json in the current folder."""
530
+
531
+ import json
532
+ from pathlib import Path
533
+
534
+ PATH = Path("todos.json")
535
+
536
+
537
+ def load():
538
+ if not PATH.exists():
539
+ return []
540
+ return json.loads(PATH.read_text())
541
+
542
+
543
+ def save(todos):
544
+ PATH.write_text(json.dumps(todos, indent=2))
545
+ ''',
546
+ "todo.py": '''\
547
+ """A tiny todo list: python todo.py add TITLE | list | done ID"""
548
+
549
+ import argparse
550
+
551
+ import storage
552
+
553
+
554
+ def add(title):
555
+ todos = storage.load()
556
+ next_id = max((t["id"] for t in todos), default=0) + 1
557
+ todos.append({"id": next_id, "title": title, "done": False})
558
+ storage.save(todos)
559
+ print(f"Added #{next_id}")
560
+
561
+
562
+ def done(todo_id):
563
+ todos = storage.load()
564
+ for todo in todos:
565
+ if todo["id"] == todo_id:
566
+ todo["done"] = True
567
+ storage.save(todos)
568
+ print(f"Done #{todo_id}")
569
+ return
570
+ raise SystemExit(f"No todo #{todo_id}")
571
+
572
+
573
+ def format_todo(todo):
574
+ return f"#{todo['id']} [{'x' if todo['done'] else ' '}] {todo['title']}"
575
+
576
+
577
+ def list_todos():
578
+ for todo in storage.load():
579
+ print(format_todo(todo))
580
+
581
+
582
+ def main(argv=None):
583
+ parser = argparse.ArgumentParser(description="A tiny todo list")
584
+ commands = parser.add_subparsers(dest="command", required=True)
585
+ add_command = commands.add_parser("add")
586
+ add_command.add_argument("title")
587
+ done_command = commands.add_parser("done")
588
+ done_command.add_argument("id", type=int)
589
+ commands.add_parser("list")
590
+ args = parser.parse_args(argv)
591
+ if args.command == "add":
592
+ add(args.title)
593
+ elif args.command == "done":
594
+ done(args.id)
595
+ else:
596
+ list_todos()
597
+
598
+
599
+ if __name__ == "__main__":
600
+ main()
601
+ ''',
602
+ "test_todo.py": """\
603
+ import contextlib
604
+ import io
605
+ import tempfile
606
+ import unittest
607
+ from pathlib import Path
608
+
609
+ import storage
610
+ import todo
611
+
612
+
613
+ def run(*argv):
614
+ out = io.StringIO()
615
+ with contextlib.redirect_stdout(out):
616
+ todo.main(list(argv))
617
+ return out.getvalue().splitlines()
618
+
619
+
620
+ class TodoTest(unittest.TestCase):
621
+ def setUp(self):
622
+ self.folder = tempfile.TemporaryDirectory()
623
+ storage.PATH = Path(self.folder.name) / "todos.json"
624
+
625
+ def tearDown(self):
626
+ self.folder.cleanup()
627
+
628
+ def test_add_and_list(self):
629
+ self.assertEqual(run("add", "buy milk"), ["Added #1"])
630
+ self.assertEqual(run("add", "call mom"), ["Added #2"])
631
+ self.assertEqual(run("list"), ["#1 [ ] buy milk", "#2 [ ] call mom"])
632
+
633
+ def test_done(self):
634
+ run("add", "buy milk")
635
+ self.assertEqual(run("done", "1"), ["Done #1"])
636
+ self.assertEqual(run("list"), ["#1 [x] buy milk"])
637
+ """,
638
+ }
639
+
640
+ TODO_STEPS = [
641
+ (["add", "low thing", "--priority", "low"], ["Added #2"]),
642
+ (["add", "urgent", "--priority", "high"], ["Added #3"]),
643
+ (["add", "normal thing"], ["Added #4"]),
644
+ (["add", "urgent two", "--priority", "high"], ["Added #5"]),
645
+ (["done", "4"], ["Done #4"]),
646
+ (
647
+ ["list"],
648
+ ["#3 [ ] ! urgent", "#5 [ ] ! urgent two", "#1 [ ] old task", "#4 [x] normal thing", "#2 [ ] low thing"],
649
+ ),
650
+ ]
651
+
652
+
653
+ def check_feature(folder: Path, answer: str) -> Check:
654
+ tests = unittest_check(folder, "its tests")
655
+ if not tests.passed:
656
+ return tests
657
+ trial = folder / "hidden-check"
658
+ trial.mkdir()
659
+ for source in folder.glob("*.py"):
660
+ shutil.copy(source, trial / source.name)
661
+ (trial / "todos.json").write_text('[{"id": 1, "title": "old task", "done": false}]') # saved before the change
662
+ for argv, want in TODO_STEPS:
663
+ r = run_python(trial, "todo.py", *argv)
664
+ got = [line.rstrip() for line in r.stdout.strip().splitlines()]
665
+ if r.returncode != 0 or got != want:
666
+ shown = " | ".join(got)[:120] or last_line(r.stderr)
667
+ return Check(False, f"`todo.py {' '.join(argv)}` printed {shown!r}, expected {' | '.join(want)!r}")
668
+ r = run_python(trial, "todo.py", "add", "x", "--priority", "urgent")
669
+ if r.returncode == 0:
670
+ return Check(False, "accepted an unknown priority")
671
+ return Check(True, "priorities work, including old todos")
672
+
673
+
674
+ # ----------------------------------------------------------------------------- the suite
675
+
676
+ TASKS = [
677
+ Task(
678
+ "fix-bug",
679
+ "Fix a bug so the tests pass",
680
+ "The tests in test_cart.py fail. Find the bug, fix it, and run the tests.",
681
+ {"cart.py": CART, "test_cart.py": CART_TESTS},
682
+ check_fix_bug,
683
+ ),
684
+ Task(
685
+ "find-code",
686
+ "Answer with file:line",
687
+ "Which function computes how long to wait before retrying a failed upload in this project? Reply with "
688
+ "the function name and where it is defined, as path:line (for example `pkg/module.py:12`).",
689
+ UPLOADER,
690
+ check_find_code,
691
+ ),
692
+ Task(
693
+ "write-script",
694
+ "Write a script from a spec",
695
+ "Write a Python script stats.py that reads numbers.txt (one integer per line; ignore blank lines) and "
696
+ "prints exactly these four lines:\ncount: <how many numbers>\nsum: <their sum>\n"
697
+ "mean: <the mean, with 2 decimals>\nmedian: <the median, with 2 decimals>\nThen run it to check the output.",
698
+ {"numbers.txt": NUMBERS},
699
+ check_write_script,
700
+ ),
701
+ Task(
702
+ "rename",
703
+ "Rename a function across files",
704
+ "Rename the function get_user_name to display_name everywhere in this project: the definition, every "
705
+ "caller and the tests. Then run the tests with `python -m unittest`.",
706
+ SHOP,
707
+ check_rename,
708
+ ),
709
+ Task(
710
+ "precise-edit",
711
+ "One-line edit in a long file",
712
+ f"In services.py, change the timeout of {TARGET_SERVICE} from 30 to 45. Change nothing else.",
713
+ {"services.py": SERVICES},
714
+ check_precise_edit,
715
+ ),
716
+ Task(
717
+ "recover",
718
+ "Recover from failing commands",
719
+ "Build report.txt from data.txt by running: python tool.py --input data.txt --out report.txt\n"
720
+ "If a line in data.txt is invalid, delete that line from data.txt and run the tool again.",
721
+ {"tool.py": TOOL, "data.txt": SCORES},
722
+ check_recover,
723
+ ),
724
+ Task(
725
+ "spec-fix",
726
+ "Fix a function to match its spec",
727
+ "Users report that parse_duration in durations.py is wrong: parse_duration('1h30m') returns 1800 instead "
728
+ "of 5400. Fix it so it does everything its docstring says. Add tests for the cases in the docstring to "
729
+ "test_durations.py and run them.",
730
+ {"durations.py": DURATIONS},
731
+ check_spec_fix,
732
+ ),
733
+ Task(
734
+ "feature",
735
+ "Add a feature across files",
736
+ "Add priorities to this todo app. `python todo.py add TITLE --priority high|normal|low` sets one "
737
+ "(the default is normal). `list` shows high-priority todos first, then normal, then low, keeping the order "
738
+ 'they were added within each priority, and marks high-priority todos with "!" after the checkbox, like '
739
+ "`#3 [ ] ! call the bank`. Todos saved before this change have no priority and count as normal. Update "
740
+ "the tests and run them.",
741
+ TODO_APP,
742
+ check_feature,
743
+ ),
744
+ ]
745
+
746
+
747
+ def select_tasks(ids: str | None) -> list[Task]:
748
+ if not ids:
749
+ return list(TASKS)
750
+ wanted = [i.strip() for i in ids.split(",") if i.strip()]
751
+ known = {task.id: task for task in TASKS}
752
+ unknown = [i for i in wanted if i not in known]
753
+ if unknown:
754
+ raise ValueError(f"unknown task(s) {', '.join(unknown)}; choose from {', '.join(known)}")
755
+ return [known[i] for i in wanted]
756
+
757
+
758
+ # ----------------------------------------------------------------------------- running
759
+
760
+
761
+ @dataclass
762
+ class TaskResult:
763
+ id: str
764
+ passed: bool
765
+ seconds: float
766
+ detail: str
767
+ steps: int = 0 # model responses
768
+ tool_errors: int = 0 # failed tool calls, including malformed ones
769
+ error: str | None = None # why the run stopped early: a timeout or an Ollama error
770
+
771
+
772
+ @dataclass
773
+ class ModelRun:
774
+ model: str # as given: a catalog key or Ollama tag
775
+ ollama_model: str
776
+ context: int = 0
777
+ num_batch: int | None = None
778
+ think: bool = True
779
+ load_seconds: float = 0.0
780
+ prompt_tps: float | None = None # measured with a fresh prompt; see prompt_speed()
781
+ memory_gb: float | None = None
782
+ gpu_percent: int | None = None
783
+ usage: dict = field(default_factory=dict)
784
+ tasks: list[TaskResult] = field(default_factory=list)
785
+ error: str | None = None # the model couldn't run at all
786
+ interrupted: bool = False
787
+
788
+ @property
789
+ def passed(self) -> int:
790
+ return sum(t.passed for t in self.tasks)
791
+
792
+ @property
793
+ def seconds(self) -> float:
794
+ return sum(t.seconds for t in self.tasks)
795
+
796
+ @property
797
+ def generation_tps(self) -> float | None:
798
+ ns = self.usage.get("output_ns", 0)
799
+ return round(self.usage["output_tokens"] / (ns / 1e9), 1) if ns else None
800
+
801
+ @property
802
+ def tool_errors(self) -> int:
803
+ return sum(t.tool_errors for t in self.tasks)
804
+
805
+ def to_json(self) -> dict:
806
+ data = asdict(self)
807
+ data.update(
808
+ passed=self.passed,
809
+ total=len(self.tasks),
810
+ seconds=round(self.seconds, 1),
811
+ generation_tps=self.generation_tps,
812
+ tool_errors=self.tool_errors,
813
+ )
814
+ return data
815
+
816
+
817
+ @contextmanager
818
+ def time_limit(seconds: float) -> Iterator[None]:
819
+ def expire(signum, frame):
820
+ raise TaskTimeout
821
+
822
+ previous = signal.signal(signal.SIGALRM, expire)
823
+ signal.setitimer(signal.ITIMER_REAL, seconds)
824
+ try:
825
+ yield
826
+ finally:
827
+ signal.setitimer(signal.ITIMER_REAL, 0)
828
+ signal.signal(signal.SIGALRM, previous)
829
+
830
+
831
+ class Elapsed:
832
+ def __init__(self, label: str):
833
+ self.label, self.start = label, time.monotonic()
834
+
835
+ def __rich__(self) -> Text:
836
+ return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
837
+
838
+
839
+ def count_tool_errors(messages: list[dict]) -> int:
840
+ failed = sum(1 for m in messages if m.get("role") == "tool" and m.get("content", "").startswith("Error:"))
841
+ malformed = sum(
842
+ 1
843
+ for m in messages
844
+ if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
845
+ )
846
+ return failed + malformed
847
+
848
+
849
+ def run_task(
850
+ ollama: Ollama, settings: Settings, task: Task, timeout: float, log: Console, keep: bool, usage: dict
851
+ ) -> TaskResult:
852
+ folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
853
+ for rel, content in task.files.items():
854
+ (folder / rel).parent.mkdir(parents=True, exist_ok=True)
855
+ (folder / rel).write_text(content)
856
+ agent = Agent(ollama, settings, folder, console=log)
857
+ error = None
858
+ started = time.monotonic()
859
+ try:
860
+ with time_limit(timeout):
861
+ agent.run_turn(task.prompt)
862
+ except TaskTimeout:
863
+ error = f"timed out after {timeout:.0f}s"
864
+ except OllamaError as e:
865
+ error = str(e).splitlines()[0][:200]
866
+ seconds = time.monotonic() - started
867
+ for key, value in agent.usage.items():
868
+ usage[key] = usage.get(key, 0) + value
869
+ answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
870
+ try:
871
+ check = task.check(folder, answer)
872
+ except Exception as e: # a check must never crash the benchmark
873
+ check = Check(False, f"check failed: {type(e).__name__}: {e}")
874
+ if keep:
875
+ (folder / "lcode-bench.log").write_text(log.file.getvalue() if isinstance(log.file, io.StringIO) else "")
876
+ else:
877
+ shutil.rmtree(folder, ignore_errors=True)
878
+ return TaskResult(
879
+ id=task.id,
880
+ passed=check.passed and error is None,
881
+ seconds=round(seconds, 1),
882
+ detail=error or check.detail,
883
+ steps=sum(1 for m in agent.messages if m.get("role") == "assistant"),
884
+ tool_errors=count_tool_errors(agent.messages),
885
+ error=error,
886
+ )
887
+
888
+
889
+ def prompt_speed(ollama: Ollama, settings: Settings, options: dict) -> float | None:
890
+ """Prompt tokens per second for prompts the model hasn't seen: the faster of two readings.
891
+
892
+ Measured separately: the tasks' requests mostly repeat the conversation so far, which Ollama
893
+ serves from its prompt cache, and counting those tokens would overstate the speed. The first
894
+ reading after loading can be much slower while the model warms up, hence two.
895
+ """
896
+ readings = [_read_prompt(ollama, settings, options) for _ in range(2)]
897
+ return max((r for r in readings if r), default=None)
898
+
899
+
900
+ def _read_prompt(ollama: Ollama, settings: Settings, options: dict) -> float | None:
901
+ rng = random.Random()
902
+ vocabulary = PROBE_TEXT.split()
903
+ words = " ".join(rng.choice(vocabulary) for _ in range(PROBE_WORDS))
904
+ payload = {
905
+ "model": settings.model,
906
+ "messages": [{"role": "user", "content": f"{rng.random()}\n{words}\n\nReply with OK."}],
907
+ "think": False,
908
+ "keep_alive": settings.keep_alive,
909
+ "options": {**options, "num_predict": 1},
910
+ }
911
+ final: dict = {}
912
+ try:
913
+ with time_limit(120):
914
+ for chunk in ollama.chat_stream(payload):
915
+ if chunk.get("done"):
916
+ final = chunk
917
+ except (OllamaError, TaskTimeout):
918
+ return None
919
+ count, ns = final.get("prompt_eval_count", 0), final.get("prompt_eval_duration", 0)
920
+ return round(count / (ns / 1e9), 1) if ns and count >= 1000 else None
921
+
922
+
923
+ def memory_use(ollama: Ollama, model: str) -> tuple[float | None, int | None]:
924
+ for entry in ollama.running():
925
+ if entry.get("name") in (model, f"{model}:latest") or entry.get("model") in (model, f"{model}:latest"):
926
+ size, vram = entry.get("size") or 0, entry.get("size_vram") or 0
927
+ if size:
928
+ return round(size / 1e9, 1), round(100 * vram / size)
929
+ return None, None
930
+
931
+
932
+ def run_model(
933
+ ollama: Ollama,
934
+ name: str,
935
+ settings: Settings,
936
+ tasks: list[Task],
937
+ console: Console,
938
+ timeout: float = DEFAULT_TIMEOUT,
939
+ keep: bool = False,
940
+ verbose: bool = False,
941
+ ) -> ModelRun:
942
+ run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think)
943
+ console.print(
944
+ f"\n[bold]{escape(settings.model)}[/] · {format_tokens(settings.context)} context · "
945
+ f"reasoning {'on' if settings.think else 'off'} · {len(tasks)} task{'' if len(tasks) == 1 else 's'}"
946
+ )
947
+ options = {"num_ctx": settings.context, **({"num_batch": settings.num_batch} if settings.num_batch else {})}
948
+ started = time.monotonic()
949
+ try:
950
+ with console.status(Elapsed(" Loading the model")):
951
+ ollama.load(settings.model, options, settings.keep_alive)
952
+ except OllamaError as e:
953
+ console.print(f" [yellow]Loading failed ({escape(str(e).splitlines()[0][:150])}); the tasks will retry.[/]")
954
+ except KeyboardInterrupt:
955
+ run.interrupted = True
956
+ return run
957
+ run.load_seconds = round(time.monotonic() - started, 1)
958
+ run.memory_gb, run.gpu_percent = memory_use(ollama, settings.model)
959
+ try:
960
+ with console.status(Elapsed(" Measuring prompt speed")):
961
+ run.prompt_tps = prompt_speed(ollama, settings, options)
962
+ except KeyboardInterrupt:
963
+ run.interrupted = True
964
+ return run
965
+ where = f" · {run.memory_gb} GB, {run.gpu_percent}% on the GPU" if run.memory_gb else ""
966
+ reads = f" · reads prompts at {run.prompt_tps:,.0f} tok/s" if run.prompt_tps else ""
967
+ console.print(f" [dim]Loaded in {run.load_seconds:.0f}s{where}{reads}[/]")
968
+ for task in tasks:
969
+ log = console if verbose else Console(file=io.StringIO(), width=120, force_terminal=False)
970
+ try:
971
+ if verbose:
972
+ console.rule(f"{task.id}: {task.title}")
973
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
974
+ else:
975
+ with console.status(Elapsed(f" {task.id:<13} {task.title}")):
976
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
977
+ except KeyboardInterrupt:
978
+ console.print(" [yellow]Stopped.[/]")
979
+ run.interrupted = True
980
+ break
981
+ run.tasks.append(result)
982
+ mark = "[green]✓[/]" if result.passed else "[red]✗[/]"
983
+ console.print(
984
+ f" {mark} {task.id:<13} {task.title:<32} {result.seconds:>5.0f}s [dim]{escape(result.detail)}[/]"
985
+ )
986
+ if run.memory_gb is None:
987
+ run.memory_gb, run.gpu_percent = memory_use(ollama, settings.model)
988
+ # lcode lowers the batch size or context if the GPU runs out of memory; report what was used.
989
+ run.context, run.num_batch, run.think = settings.context, settings.num_batch, settings.think
990
+ return run
991
+
992
+
993
+ # ----------------------------------------------------------------------------- reporting
994
+
995
+
996
+ def duration(seconds: float) -> str:
997
+ return f"{int(seconds // 60)}m {seconds % 60:02.0f}s" if seconds >= 60 else f"{seconds:.0f}s"
998
+
999
+
1000
+ def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, list[str]]]:
1001
+ rows = []
1002
+ for task in tasks:
1003
+ cells = []
1004
+ for run in runs:
1005
+ result = next((t for t in run.tasks if t.id == task.id), None)
1006
+ cells.append("" if result is None else f"{'✓' if result.passed else '✗'} {duration(result.seconds)}")
1007
+ rows.append((f"{task.id}: {task.title}", cells))
1008
+
1009
+ def cell(run: ModelRun, value: str) -> str:
1010
+ return "—" if run.error else value
1011
+
1012
+ rows += [
1013
+ ("Passed", [cell(r, f"{r.passed}/{len(r.tasks)}") for r in runs]),
1014
+ ("Time", [cell(r, duration(r.seconds)) for r in runs]),
1015
+ ("Generation", [cell(r, f"{r.generation_tps:.1f} tok/s" if r.generation_tps else "?") for r in runs]),
1016
+ ("Prompt reading", [cell(r, f"{r.prompt_tps:,.0f} tok/s" if r.prompt_tps else "?") for r in runs]),
1017
+ ("Tool-call errors", [cell(r, str(r.tool_errors)) for r in runs]),
1018
+ (
1019
+ "Memory",
1020
+ [cell(r, f"{r.memory_gb} GB, {r.gpu_percent}% on GPU" if r.memory_gb else "?") for r in runs],
1021
+ ),
1022
+ ("Context", [cell(r, format_tokens(r.context)) for r in runs]),
1023
+ ]
1024
+ return rows
1025
+
1026
+
1027
+ def print_summary(console: Console, runs: list[ModelRun], tasks: list[Task]) -> None:
1028
+ table = Table(title="lcode bench", title_justify="left", header_style="bold")
1029
+ table.add_column("")
1030
+ for run in runs:
1031
+ table.add_column(escape(run.model), justify="right")
1032
+ for label, cells in summary_rows(runs, tasks):
1033
+ style = "bold" if label == "Passed" else ""
1034
+ table.add_row(escape(label), *[escape(c) for c in cells], style=style)
1035
+ console.print()
1036
+ console.print(table)
1037
+ for run in runs:
1038
+ if run.error:
1039
+ console.print(f"[yellow]{escape(run.model)}: {escape(run.error)}[/]")
1040
+
1041
+
1042
+ def report(runs: list[ModelRun], hardware: Hardware, ollama_version: str) -> dict:
1043
+ """The JSON document written by --json (schema version 1)."""
1044
+ return {
1045
+ "schema": SCHEMA_VERSION,
1046
+ "lcode": __version__,
1047
+ "ollama": ollama_version,
1048
+ "date": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
1049
+ "hardware": {**asdict(hardware), "description": hardware.describe()},
1050
+ "tasks": [{"id": t.id, "title": t.title} for t in TASKS],
1051
+ "runs": [run.to_json() for run in runs],
1052
+ }
1053
+
1054
+
1055
+ def markdown(runs: list[ModelRun], tasks: list[Task], hardware: Hardware, ollama_version: str) -> str:
1056
+ """A table to paste into a model test report."""
1057
+ date = dt.date.today().isoformat()
1058
+ lines = [
1059
+ f"**lcode bench** · lcode {__version__} · Ollama {ollama_version} · {hardware.describe()} · {date}",
1060
+ "",
1061
+ "| | " + " | ".join(f"`{r.model}`" for r in runs) + " |",
1062
+ "|---|" + "---|" * len(runs),
1063
+ ]
1064
+ for label, cells in summary_rows(runs, tasks):
1065
+ bold = label == "Passed"
1066
+ label_md = f"**{label}**" if bold else label
1067
+ lines.append(f"| {label_md} | " + " | ".join(f"**{c}**" if bold and c else c for c in cells) + " |")
1068
+ return "\n".join(lines) + "\n"
1069
+
1070
+
1071
+ def write_json(path: Path, document: dict) -> None:
1072
+ path.write_text(json.dumps(document, indent=2) + "\n")
1073
+
1074
+
1075
+ def print_tasks(console: Console) -> None:
1076
+ table = Table(title="lcode bench tasks", title_justify="left", header_style="bold")
1077
+ table.add_column("Task", style="cyan")
1078
+ table.add_column("What it tests")
1079
+ for task in TASKS:
1080
+ table.add_row(task.id, task.title)
1081
+ console.print(table)
1082
+ console.print("[dim]Run a subset with --tasks, e.g. lcode bench --tasks fix-bug,rename[/]")
lcode/cli.py CHANGED
@@ -6,6 +6,7 @@ import argparse
6
6
  import platform
7
7
  import shutil
8
8
  import sys
9
+ import tempfile
9
10
  from pathlib import Path
10
11
 
11
12
  import requests
@@ -343,6 +344,79 @@ def cmd_config(args) -> None:
343
344
  fail(str(e))
344
345
 
345
346
 
347
+ # ----------------------------------------------------------------------------- lcode bench
348
+
349
+
350
+ def cmd_bench(args) -> None:
351
+ from lcode import bench
352
+
353
+ if args.list:
354
+ bench.print_tasks(console)
355
+ return
356
+ try:
357
+ cfg = config.load()
358
+ context = parse_context(args.context) if args.context else bench.DEFAULT_CONTEXT
359
+ tasks = bench.select_tasks(args.tasks)
360
+ except (ConfigError, ValueError) as e:
361
+ fail(str(e))
362
+ if args.timeout <= 0:
363
+ fail("--timeout must be a positive number of seconds")
364
+ ollama = Ollama(cfg["ollama_host"])
365
+ hw = detect()
366
+ version = check_ollama(ollama, hw)
367
+ console.print(f"[bold]lcode bench[/] · {hw.describe()} · Ollama {version}")
368
+ console.print("[dim]Each task runs in a new temporary folder with every permission granted and web access off.[/]")
369
+ names = args.models or [cfg["model"]]
370
+ others = [m.get("name", "?") for m in ollama.running()]
371
+ if others:
372
+ console.print(
373
+ f"[yellow]Already loaded in Ollama: {', '.join(others)}. Models that share the GPU run slower; "
374
+ "for comparable results, stop other sessions first.[/]"
375
+ )
376
+ runs = []
377
+ for i, name in enumerate(names):
378
+ try:
379
+ model, spec = resolve_model(ollama, name)
380
+ except NotInstalled as e:
381
+ console.print(f"\n[yellow]Skipping {name}: {e}[/]")
382
+ runs.append(bench.ModelRun(name, name, error=str(e)))
383
+ continue
384
+ ctx, note = choose_context(ollama, model, spec, context, hw)
385
+ if note:
386
+ console.print(f"[yellow]{model}, context {format_tokens(ctx)}: {note}[/]")
387
+ num_batch = cfg["num_batch"]
388
+ if num_batch is None and spec and spec.num_batch and model == spec.local_name:
389
+ num_batch = spec.num_batch
390
+ settings = Settings(
391
+ model=model,
392
+ context=ctx,
393
+ num_batch=num_batch,
394
+ keep_alive=cfg["keep_alive"],
395
+ think=cfg["think"] and not args.no_think,
396
+ permission_mode="yolo",
397
+ web="off",
398
+ checkpoints=False,
399
+ )
400
+ run = bench.run_model(ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose)
401
+ runs.append(run)
402
+ if run.interrupted:
403
+ break
404
+ if i < len(names) - 1:
405
+ try:
406
+ ollama.unload(model) # so the next model gets all the GPU memory
407
+ except OllamaError:
408
+ pass
409
+ bench.print_summary(console, runs, tasks)
410
+ if args.json:
411
+ path = Path(args.json).expanduser()
412
+ bench.write_json(path, bench.report(runs, hw, version))
413
+ console.print(f"Results saved to {path}")
414
+ if args.markdown:
415
+ print("\n" + bench.markdown(runs, tasks, hw, version))
416
+ if args.keep:
417
+ console.print(f"[dim]Task folders are kept in {tempfile.gettempdir()} (lcode-bench-*).[/]")
418
+
419
+
346
420
  # ----------------------------------------------------------------------------- lcode (chat)
347
421
 
348
422
 
@@ -400,7 +474,7 @@ def build_parser() -> argparse.ArgumentParser:
400
474
  parser = argparse.ArgumentParser(
401
475
  prog="lcode",
402
476
  description="A local-first terminal coding agent powered by open-weight models via Ollama.",
403
- epilog="Subcommands: lcode setup | models | doctor | config (lcode <subcommand> --help). "
477
+ epilog="Subcommands: lcode setup | models | doctor | config | bench (lcode <subcommand> --help). "
404
478
  "Docs: https://nasser1941.github.io/lcode/",
405
479
  )
406
480
  parser.add_argument("-p", "--prompt", help="run one request non-interactively and exit")
@@ -438,10 +512,25 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
438
512
  p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
439
513
  p.add_argument("value", nargs="?")
440
514
  subs["config"] = p
515
+ p = argparse.ArgumentParser(
516
+ prog="lcode bench",
517
+ description="Score models on small coding tasks on this machine: pass rate, speed and memory.",
518
+ )
519
+ p.add_argument("models", nargs="*", help="catalog keys or Ollama tags (default: the configured model)")
520
+ p.add_argument("--context", "--ctx", dest="context", help="context window (default: 32k, the same everywhere)")
521
+ p.add_argument("--tasks", help="comma-separated task ids to run (default: all; see --list)")
522
+ p.add_argument("--timeout", type=float, default=300, help="seconds per task (default: 300)")
523
+ p.add_argument("--json", metavar="FILE", help="also save the results as JSON")
524
+ p.add_argument("--markdown", action="store_true", help="also print a Markdown table for a test report")
525
+ p.add_argument("--no-think", action="store_true", help="run without model reasoning")
526
+ p.add_argument("--keep", action="store_true", help="keep each task's folder and transcript for inspection")
527
+ p.add_argument("-v", "--verbose", action="store_true", help="show the model working, like a normal session")
528
+ p.add_argument("--list", action="store_true", help="list the tasks and exit")
529
+ subs["bench"] = p
441
530
  return subs
442
531
 
443
532
 
444
- COMMANDS = {"setup": cmd_setup, "models": cmd_models, "doctor": cmd_doctor, "config": cmd_config}
533
+ COMMANDS = {"setup": cmd_setup, "models": cmd_models, "doctor": cmd_doctor, "config": cmd_config, "bench": cmd_bench}
445
534
 
446
535
 
447
536
  def main(argv: list[str] | None = None) -> None:
lcode/ollama.py CHANGED
@@ -147,6 +147,10 @@ class Ollama:
147
147
  raise OllamaError(f"Ollama error: {chunk['error']}")
148
148
  yield chunk
149
149
 
150
+ def unload(self, model: str) -> None:
151
+ """Free the memory a model is using."""
152
+ self._post("/api/generate", {"model": model, "keep_alive": 0}, timeout=60)
153
+
150
154
  def load(self, model: str, options: dict, keep_alive: str) -> None:
151
155
  """Load a model into memory without generating anything."""
152
156
  self._post(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -73,7 +73,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
73
73
  plans multi-step tasks with a visible todo list.
74
74
  - **Long context.** 256K tokens with the default model (1M with Nemotron). You choose the window.
75
75
  - **Hardware-aware.** Detects your NVIDIA GPU or Apple Silicon Mac and picks the best model and the
76
- largest context that fits: `lcode setup` does it in one step.
76
+ largest context that fits: `lcode setup` does it in one step. `lcode bench` compares models on
77
+ real coding tasks on your machine.
77
78
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
78
79
  your approval. `auto-edit` and `yolo` modes when you want speed.
79
80
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
@@ -125,6 +126,7 @@ lcode
125
126
  | `lcode -c` / `lcode --resume` | continue the last session here / pick a saved session from a list |
126
127
  | `lcode --model qwen3.5-9b --context 128k` | pick a model and context window for this session |
127
128
  | `lcode models` / `lcode doctor` | what fits this machine / check the installation |
129
+ | `lcode bench qwen3.6-35b qwen3.5-9b` | compare models on small coding tasks on this machine |
128
130
  | `/undo`, `/rewind` | take back the last request's file changes, or go back further |
129
131
  | `/rename`, `/resume` | name the current session, resume a saved one |
130
132
  | `/model`, `/context`, `/compact`, `/help` | switch model, resize the context window (pick from a list), summarize, list commands |
@@ -170,7 +172,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
170
172
 
171
173
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
172
174
 
173
- - [`lcode bench`: score models on your own hardware](https://github.com/nasser1941/lcode/issues/5)
174
175
  - [MCP tool servers](https://github.com/nasser1941/lcode/issues/6)
175
176
  - [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
176
177
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
@@ -1,22 +1,23 @@
1
- lcode/__init__.py,sha256=6o9drjzBZ6xhvmNDRH_G_fyZUs1QniLoDVSjRcEmymU,117
1
+ lcode/__init__.py,sha256=7co3jkJP_ri78Ap0JVyQJkT8sWh6fKXdJUCuT5A5Ab4,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=NtLV_QI75Ixvs-6Ig39qhMb2kPuIkK4z1qMclyBb3sQ,25335
3
+ lcode/agent.py,sha256=VYYGmkQi8CnP7mXgA2n_tjXnYn44uwK00j4Ow7vGKmg,25776
4
+ lcode/bench.py,sha256=72TSBit063ERFlzgsq2xuCSzWaCLGoZKhB3G_E5HlBM,37751
4
5
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
5
6
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
6
- lcode/cli.py,sha256=rU5XYsYmnUU-N9N1GX_s0oWSwgqYM-8k_TEKR0tlzvs,20143
7
+ lcode/cli.py,sha256=h830l_4BTgQCZULcKbMvKMV57v5g6Va0livr83vAF1E,24295
7
8
  lcode/config.py,sha256=xkD7L5gSrHbgnXt7C8SYktNXO62BqNIXtuGyCY-y4FY,6002
8
9
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
9
10
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
10
11
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
11
- lcode/ollama.py,sha256=fELwqw7xqLH9TM7XvdI1pxqYZFeIRJBv-VSz9REdvwA,6516
12
+ lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
12
13
  lcode/permissions.py,sha256=wJKwhJQx44AzjOFZCWwM4M2oUlPFUNYI2cIVfoBGGgc,3898
13
14
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
14
15
  lcode/repl.py,sha256=F5DsLYya5UmJt2LvJf-N_3CdBKxiK8sr7B0zNSS7gKI,26525
15
16
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
16
17
  lcode/tools.py,sha256=0JFM-owT3K243tvMhHlcfWKSMyHSa44GY4A2GebKU0k,25935
17
18
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
18
- lcode_cli-0.3.0.dist-info/METADATA,sha256=o6_TdM5_SW2xs3j5SyjtNE6iIfagy9ivcBS29xplJ_c,9423
19
- lcode_cli-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
20
- lcode_cli-0.3.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
21
- lcode_cli-0.3.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
22
- lcode_cli-0.3.0.dist-info/RECORD,,
19
+ lcode_cli-0.3.1.dist-info/METADATA,sha256=8sici8niZzAGQwTLMDDsOckVIbZmYB3lXZ8Cqpn7F2o,9490
20
+ lcode_cli-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ lcode_cli-0.3.1.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
22
+ lcode_cli-0.3.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
23
+ lcode_cli-0.3.1.dist-info/RECORD,,