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 +1 -1
- lcode/agent.py +6 -0
- lcode/bench.py +1082 -0
- lcode/cli.py +91 -2
- lcode/ollama.py +4 -0
- {lcode_cli-0.3.0.dist-info → lcode_cli-0.3.1.dist-info}/METADATA +4 -3
- {lcode_cli-0.3.0.dist-info → lcode_cli-0.3.1.dist-info}/RECORD +10 -9
- {lcode_cli-0.3.0.dist-info → lcode_cli-0.3.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.3.0.dist-info → lcode_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.3.0.dist-info → lcode_cli-0.3.1.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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.
|
|
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=
|
|
1
|
+
lcode/__init__.py,sha256=7co3jkJP_ri78Ap0JVyQJkT8sWh6fKXdJUCuT5A5Ab4,117
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
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=
|
|
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
|
|
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.
|
|
19
|
-
lcode_cli-0.3.
|
|
20
|
-
lcode_cli-0.3.
|
|
21
|
-
lcode_cli-0.3.
|
|
22
|
-
lcode_cli-0.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|