lcode-cli 0.2.2__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/checkpoints.py ADDED
@@ -0,0 +1,449 @@
1
+ """Automatic checkpoints, so /undo can take back what the model changed.
2
+
3
+ Before the first change in a request (a file edit, or a shell command that isn't read-only), lcode
4
+ snapshots the project into a private git repository in the state directory, and snapshots it again
5
+ when the request ends. /undo puts the files that request changed back the way they were.
6
+
7
+ The private repository is separate from the user's own: their index, HEAD, branches, stash and
8
+ objects are never touched. It covers every file that isn't ignored, including changes made by shell
9
+ commands, and works in folders that aren't git repositories.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as dt
15
+ import hashlib
16
+ import os
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ import time
21
+ from dataclasses import asdict, dataclass, field, fields
22
+ from pathlib import Path
23
+
24
+ from rich.console import Console
25
+ from rich.markup import escape
26
+
27
+ from lcode import config
28
+
29
+ KEEP_DAYS = 14 # checkpoints of older sessions are deleted
30
+ MAX_FILES = 20_000 # larger folders aren't checkpointed
31
+ MAX_TOTAL_BYTES = 1024**3
32
+ MAX_FILE_BYTES = 10 * 1024**2 # larger files are left out of checkpoints
33
+ GIT_TIMEOUT = 120
34
+ LOCK_RETRIES = 20
35
+ TITLE_LENGTH = 70
36
+ DEFAULT_EXCLUDES = """\
37
+ # Generated folders, left out even where no .gitignore mentions them
38
+ node_modules/
39
+ .venv/
40
+ venv/
41
+ __pycache__/
42
+ *.pyc
43
+ .mypy_cache/
44
+ .pytest_cache/
45
+ .ruff_cache/
46
+ .tox/
47
+ .gradle/
48
+ .next/
49
+ .DS_Store
50
+ """
51
+ GIT_CONFIG = (
52
+ "core.autocrlf=false",
53
+ "core.safecrlf=false",
54
+ "core.quotepath=false",
55
+ "core.fsmonitor=false",
56
+ "commit.gpgsign=false",
57
+ "advice.addEmbeddedRepo=false",
58
+ "gc.auto=0",
59
+ )
60
+ IDENTITY = {
61
+ "GIT_AUTHOR_NAME": "lcode",
62
+ "GIT_AUTHOR_EMAIL": "lcode@localhost",
63
+ "GIT_COMMITTER_NAME": "lcode",
64
+ "GIT_COMMITTER_EMAIL": "lcode@localhost",
65
+ }
66
+
67
+
68
+ class CheckpointError(Exception):
69
+ pass
70
+
71
+
72
+ @dataclass
73
+ class Checkpoint:
74
+ n: int
75
+ request: str # the start of the request, for display and for finding it in the conversation
76
+ worktree: str
77
+ before: str # commit with the files as they were before the request
78
+ after: str = "" # commit with the files after it; empty if lcode stopped before recording it
79
+ message_index: int = 0 # where the request starts in the conversation
80
+ created: float = 0.0
81
+ changes: list[dict] = field(default_factory=list) # [{"status": "A" | "M" | "D", "path": ...}]
82
+ undone: bool = False
83
+
84
+ @property
85
+ def title(self) -> str:
86
+ text = " ".join(self.request.split())
87
+ return text if len(text) <= TITLE_LENGTH else text[: TITLE_LENGTH - 1].rstrip() + "…"
88
+
89
+ @property
90
+ def paths(self) -> list[str]:
91
+ return [c["path"] for c in self.changes]
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: dict) -> Checkpoint:
95
+ known = {f.name for f in fields(cls)}
96
+ return cls(**{k: v for k, v in data.items() if k in known})
97
+
98
+
99
+ @dataclass
100
+ class Restore:
101
+ """What /undo or /rewind will do: which checkpoints it reverts and which files it touches."""
102
+
103
+ target: Checkpoint # files go back to how they were before this request
104
+ undoing: list[Checkpoint]
105
+ restore: list[str] # files to put back (modified or deleted since)
106
+ remove: list[str] # files the requests created
107
+ conflicts: list[str] # files that changed again after lcode changed them
108
+ current: str # tree with the files as they are now
109
+
110
+
111
+ def stores_dir() -> Path:
112
+ return config.STATE_DIR / "checkpoints"
113
+
114
+
115
+ def work_tree_for(cwd: Path) -> Path:
116
+ """The folder a checkpoint covers: the enclosing git repository, or the folder itself."""
117
+ try:
118
+ r = subprocess.run(
119
+ ["git", "rev-parse", "--show-toplevel"],
120
+ cwd=cwd,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ env=_clean_env(),
125
+ )
126
+ except (OSError, subprocess.SubprocessError):
127
+ return cwd.resolve()
128
+ top = r.stdout.strip()
129
+ return Path(top).resolve() if r.returncode == 0 and top else cwd.resolve()
130
+
131
+
132
+ def _clean_env() -> dict[str, str]:
133
+ return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
134
+
135
+
136
+ def _exclude_pattern(path: str) -> str:
137
+ """An exclude line matching exactly this path (relative to the work tree)."""
138
+ escaped = "".join("\\" + ch if ch in "\\*?[" else ch for ch in path)
139
+ if escaped.endswith(" "):
140
+ escaped = escaped[:-1] + "\\ "
141
+ return "/" + escaped
142
+
143
+
144
+ class Store:
145
+ """A private git repository with the snapshots of one work tree."""
146
+
147
+ def __init__(self, worktree: Path):
148
+ self.worktree = worktree
149
+ key = hashlib.sha256(str(worktree).encode()).hexdigest()[:16]
150
+ self.git_dir = stores_dir() / f"{key}.git"
151
+
152
+ def git(self, *args: str, stdin: bytes | None = None, check: bool = True, timeout: int = GIT_TIMEOUT) -> str:
153
+ env = _clean_env()
154
+ env.update(IDENTITY, GIT_DIR=str(self.git_dir), GIT_WORK_TREE=str(self.worktree), GIT_LITERAL_PATHSPECS="1")
155
+ cmd = ["git"]
156
+ for option in GIT_CONFIG:
157
+ cmd += ["-c", option]
158
+ cmd += args
159
+ for attempt in range(LOCK_RETRIES):
160
+ try:
161
+ r = subprocess.run(cmd, cwd=self.worktree, env=env, input=stdin, capture_output=True, timeout=timeout)
162
+ except FileNotFoundError as e:
163
+ raise CheckpointError("git isn't installed") from e
164
+ except subprocess.TimeoutExpired as e:
165
+ raise CheckpointError(f"git {args[0]} took longer than {timeout}s") from e
166
+ except OSError as e:
167
+ raise CheckpointError(str(e)) from e
168
+ err = r.stderr.decode(errors="replace").strip()
169
+ if r.returncode != 0 and ".lock" in err:
170
+ if attempt < LOCK_RETRIES - 1:
171
+ time.sleep(0.25) # another lcode session in the same folder is saving a checkpoint
172
+ continue
173
+ raise CheckpointError(err.splitlines()[-1])
174
+ if check and r.returncode != 0:
175
+ raise CheckpointError(err.splitlines()[-1] if err else f"git {args[0]} failed")
176
+ return r.stdout.decode("utf-8", "surrogateescape")
177
+ raise AssertionError("unreachable")
178
+
179
+ def ensure(self) -> None:
180
+ if (self.git_dir / "HEAD").exists():
181
+ return
182
+ home = Path.home().resolve()
183
+ if self.worktree == home or self.worktree in home.parents:
184
+ raise CheckpointError(f"{self.worktree} is your home folder or above it; start lcode in a project folder")
185
+ self.git_dir.parent.mkdir(parents=True, exist_ok=True)
186
+ try:
187
+ r = subprocess.run(
188
+ ["git", "init", "-q", "--bare", str(self.git_dir)], capture_output=True, env=_clean_env(), timeout=30
189
+ )
190
+ except FileNotFoundError as e:
191
+ raise CheckpointError("git isn't installed") from e
192
+ if r.returncode != 0:
193
+ raise CheckpointError(r.stderr.decode(errors="replace").strip() or "git init failed")
194
+ excludes = DEFAULT_EXCLUDES
195
+ own = self.worktree / ".git" / "info" / "exclude" # the project's private ignore rules
196
+ if own.is_file():
197
+ excludes += "\n" + own.read_text(errors="replace")
198
+ (self.git_dir / "info").mkdir(exist_ok=True)
199
+ (self.git_dir / "info" / "exclude").write_text(excludes)
200
+ (self.git_dir / "lcode-worktree").write_text(str(self.worktree) + "\n")
201
+
202
+ def _candidates(self, limit: int | None = None) -> list[str]:
203
+ """Files that are new or changed since the last snapshot (every file, for the first one)."""
204
+ out = self.git("ls-files", "-z", "--others", "--modified", "--exclude-standard", timeout=60)
205
+ paths = [p for p in out.split("\0") if p]
206
+ if limit is not None and len(paths) > limit:
207
+ raise CheckpointError(f"{self.worktree} has more than {limit:,} files")
208
+ return paths
209
+
210
+ def _skip_large_files(self, first: bool) -> None:
211
+ large, total = [], 0
212
+ for rel in self._candidates(MAX_FILES if first else None):
213
+ try:
214
+ st = (self.worktree / rel).lstat()
215
+ except OSError:
216
+ continue
217
+ if not stat.S_ISREG(st.st_mode):
218
+ continue
219
+ if st.st_size > MAX_FILE_BYTES:
220
+ large.append(rel)
221
+ else:
222
+ total += st.st_size
223
+ if first and total > MAX_TOTAL_BYTES:
224
+ raise CheckpointError(f"{self.worktree} has more than {MAX_TOTAL_BYTES // 1024**3} GB of files")
225
+ if large:
226
+ with (self.git_dir / "info" / "exclude").open("a") as f:
227
+ f.write("".join(_exclude_pattern(rel) + "\n" for rel in large))
228
+ self.git("rm", "-q", "-r", "--cached", "--ignore-unmatch", "--", *large)
229
+
230
+ def tree(self) -> str:
231
+ """Record the files as they are now; returns the tree id."""
232
+ self.ensure()
233
+ first = not (self.git_dir / "index").exists()
234
+ self._skip_large_files(first)
235
+ self.git("add", "-A", "--ignore-errors", check=False) # unreadable files are skipped
236
+ (self.git_dir / "lcode-last-used").touch()
237
+ return self.git("write-tree").strip()
238
+
239
+ def commit(self, tree: str, message: str, parent: str = "") -> str:
240
+ return self.git("commit-tree", tree, "-m", message, *(["-p", parent] if parent else [])).strip()
241
+
242
+ def tree_of(self, commit: str) -> str:
243
+ return self.git("rev-parse", f"{commit}^{{tree}}").strip()
244
+
245
+ def changes(self, a: str, b: str, paths: list[str] | None = None) -> list[dict]:
246
+ """Files that differ between two commits or trees: status A (only in b), D (only in a) or M."""
247
+ args = ["diff", "--name-status", "--no-renames", "--no-ext-diff", "-z", a, b]
248
+ if paths is not None and not paths:
249
+ return []
250
+ if paths is not None and len(paths) <= 500:
251
+ args += ["--", *paths]
252
+ parts = self.git(*args).split("\0")
253
+ wanted = set(paths) if paths is not None else None
254
+ out = []
255
+ for status, path in zip(parts[0::2], parts[1::2], strict=False):
256
+ if status and path and (wanted is None or path in wanted):
257
+ out.append({"status": status[0] if status[0] in "AD" else "M", "path": path})
258
+ return out
259
+
260
+ def restore_files(self, commit: str, paths: list[str]) -> None:
261
+ for i in range(0, len(paths), 200): # works with any git version, unlike `git restore`
262
+ self.git("checkout", commit, "--", *paths[i : i + 200])
263
+
264
+ def remove_files(self, paths: list[str]) -> None:
265
+ for rel in paths:
266
+ p = self.worktree / rel
267
+ if p.is_symlink() or p.is_file():
268
+ p.unlink()
269
+ parent = p.parent
270
+ while parent != self.worktree and self.worktree in parent.parents:
271
+ try:
272
+ parent.rmdir() # only succeeds while empty
273
+ except OSError:
274
+ break
275
+ parent = parent.parent
276
+
277
+ def set_ref(self, name: str, commit: str) -> None:
278
+ self.git("update-ref", name, commit)
279
+
280
+ def delete_ref(self, name: str) -> None:
281
+ self.git("update-ref", "-d", name, check=False)
282
+
283
+ def prune(self, keep_days: int = KEEP_DAYS) -> None:
284
+ """Delete the checkpoints of sessions older than keep_days."""
285
+ if not (self.git_dir / "HEAD").exists():
286
+ return
287
+ cutoff = dt.datetime.now() - dt.timedelta(days=keep_days)
288
+ refs = self.git("for-each-ref", "--format=%(refname)", "refs/lcode/").split()
289
+ old = [ref for ref in refs if _session_time(ref.split("/")[2]) < cutoff]
290
+ for ref in old:
291
+ self.delete_ref(ref)
292
+ if old:
293
+ self.git("gc", "-q", "--prune=now", check=False, timeout=600)
294
+
295
+
296
+ def _session_time(session_id: str) -> dt.datetime:
297
+ try:
298
+ return dt.datetime.strptime(session_id[:15], "%Y%m%d-%H%M%S")
299
+ except ValueError:
300
+ return dt.datetime.min
301
+
302
+
303
+ def prune_stores(keep_days: int = KEEP_DAYS) -> None:
304
+ """Delete the checkpoint stores of folders lcode hasn't worked in for keep_days."""
305
+ cutoff = time.time() - keep_days * 86400
306
+ root = stores_dir()
307
+ if not root.is_dir():
308
+ return
309
+ for store in root.glob("*.git"):
310
+ marker = store / "lcode-last-used"
311
+ try:
312
+ last = marker.stat().st_mtime if marker.exists() else store.stat().st_mtime
313
+ except OSError:
314
+ continue
315
+ if last < cutoff:
316
+ shutil.rmtree(store, ignore_errors=True)
317
+
318
+
319
+ class Checkpoints:
320
+ """The checkpoints of one session."""
321
+
322
+ def __init__(self, console: Console, enabled: bool = True):
323
+ self.console = console
324
+ self.enabled = enabled
325
+ self.items: list[Checkpoint] = []
326
+ self.session_id = ""
327
+ self._pending: tuple[str, int] | None = None # (request, message index) of the running request
328
+ self._current: Checkpoint | None = None
329
+ self._before_tree = ""
330
+ self._disabled: dict[str, str] = {} # work tree -> why checkpoints are off there
331
+ self._pruned: set[str] = set()
332
+
333
+ # -- recording
334
+ def begin_turn(self, session_id: str, request: str, message_index: int) -> None:
335
+ self.session_id = session_id
336
+ self._pending = (request[:500], message_index)
337
+ self._current = None
338
+
339
+ def before_change(self, cwd: Path) -> None:
340
+ """Snapshot the files before the first change of the running request."""
341
+ if not self.enabled or self._pending is None or self._current is not None:
342
+ return
343
+ worktree = work_tree_for(cwd)
344
+ if str(worktree) in self._disabled:
345
+ return
346
+ store = Store(worktree)
347
+ request, index = self._pending
348
+ n = (self.items[-1].n if self.items else 0) + 1
349
+ try:
350
+ if str(worktree) not in self._pruned:
351
+ self._pruned.add(str(worktree))
352
+ prune_stores()
353
+ store.prune()
354
+ self._before_tree = store.tree()
355
+ before = store.commit(self._before_tree, f"lcode checkpoint {n} (before): {request[:200]}")
356
+ store.set_ref(self.ref(n), before)
357
+ except CheckpointError as e:
358
+ self._disabled[str(worktree)] = str(e)
359
+ self.console.print(f"[yellow]Checkpoints are off in {escape(str(worktree))}: {escape(str(e))}.[/]")
360
+ self.console.print("[yellow]/undo can't take back changes made there.[/]")
361
+ return
362
+ self._current = Checkpoint(
363
+ n=n, request=request, worktree=str(worktree), before=before, message_index=index, created=time.time()
364
+ )
365
+
366
+ def end_turn(self) -> None:
367
+ """Snapshot the files after the request and keep the checkpoint if anything changed."""
368
+ cp, self._current, self._pending = self._current, None, None
369
+ if cp is None:
370
+ return
371
+ store = Store(Path(cp.worktree))
372
+ try:
373
+ after_tree = store.tree()
374
+ if after_tree == self._before_tree:
375
+ store.delete_ref(self.ref(cp.n)) # nothing changed; nothing to undo
376
+ return
377
+ cp.after = store.commit(after_tree, f"lcode checkpoint {cp.n} (after): {cp.request[:200]}", cp.before)
378
+ store.set_ref(self.ref(cp.n), cp.after)
379
+ cp.changes = store.changes(cp.before, cp.after)
380
+ except CheckpointError as e:
381
+ self.console.print(f"[yellow]Couldn't record the files after this request: {escape(str(e))}[/]")
382
+ self.items.append(cp)
383
+ if cp.changes:
384
+ count = len(cp.changes)
385
+ self.console.print(
386
+ f"[dim]↶ Checkpoint {cp.n}: {count} file{'' if count == 1 else 's'} changed · /undo reverts them[/]"
387
+ )
388
+
389
+ def ref(self, n: int) -> str:
390
+ return f"refs/lcode/{self.session_id}/{n}"
391
+
392
+ # -- sessions
393
+ def to_json(self) -> list[dict]:
394
+ return [asdict(cp) for cp in self.items]
395
+
396
+ def load(self, session_id: str, data: list[dict]) -> None:
397
+ self.session_id = session_id
398
+ self.items = [Checkpoint.from_dict(d) for d in data if isinstance(d, dict)]
399
+
400
+ def reset(self, session_id: str) -> None:
401
+ self.session_id = session_id
402
+ self.items = []
403
+
404
+ # -- going back
405
+ def active(self) -> list[Checkpoint]:
406
+ return [cp for cp in self.items if not cp.undone]
407
+
408
+ def find(self, n: int) -> Checkpoint | None:
409
+ return next((cp for cp in self.items if cp.n == n), None)
410
+
411
+ def plan(self, target: Checkpoint) -> Restore:
412
+ """Work out how to put the files back to how they were before `target`'s request."""
413
+ undoing = [cp for cp in self.active() if cp.n >= target.n and cp.worktree == target.worktree]
414
+ store = Store(Path(target.worktree))
415
+ current = store.tree()
416
+ latest: dict[str, str] = {} # path -> commit with lcode's last version of it
417
+ for cp in undoing:
418
+ if not cp.after: # lcode stopped before recording the result; compare with the files now
419
+ cp.changes = store.changes(cp.before, current)
420
+ for path in cp.paths:
421
+ latest[path] = cp.after or current
422
+ paths = sorted(latest)
423
+ conflicts: list[str] = []
424
+ for commit in set(latest.values()):
425
+ group = [p for p in paths if latest[p] == commit]
426
+ conflicts += [c["path"] for c in store.changes(commit, current, group)]
427
+ diff = store.changes(target.before, current, paths)
428
+ return Restore(
429
+ target=target,
430
+ undoing=undoing,
431
+ restore=[c["path"] for c in diff if c["status"] != "A"],
432
+ remove=[c["path"] for c in diff if c["status"] == "A"],
433
+ conflicts=sorted(conflicts),
434
+ current=current,
435
+ )
436
+
437
+ def apply(self, plan: Restore) -> None:
438
+ store = Store(Path(plan.target.worktree))
439
+ n = plan.target.n
440
+ # Keep the files as they are now too, in case the undo itself needs undoing by hand.
441
+ store.set_ref(f"{self.ref(n)}-undo", store.commit(plan.current, f"lcode: files before undoing {n}"))
442
+ store.restore_files(plan.target.before, plan.restore)
443
+ store.remove_files(plan.remove)
444
+ for cp in plan.undoing:
445
+ cp.undone = True
446
+
447
+ def absolute_paths(self, plan: Restore) -> list[str]:
448
+ root = Path(plan.target.worktree)
449
+ return [str((root / p).resolve()) for p in [*plan.restore, *plan.remove]]
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
 
@@ -390,6 +464,7 @@ def cmd_chat(args) -> None:
390
464
  web="off" if args.no_web else cfg["web"],
391
465
  search_backend=cfg["search_backend"],
392
466
  searxng_url=cfg["searxng_url"],
467
+ checkpoints=cfg["checkpoints"],
393
468
  )
394
469
  agent = Agent(ollama, settings, cwd, console=console)
395
470
  repl(agent, prompt=args.prompt, hardware=hw, cont=args.cont, resume=args.resume)
@@ -399,7 +474,7 @@ def build_parser() -> argparse.ArgumentParser:
399
474
  parser = argparse.ArgumentParser(
400
475
  prog="lcode",
401
476
  description="A local-first terminal coding agent powered by open-weight models via Ollama.",
402
- epilog="Subcommands: lcode setup | models | doctor | config (lcode <subcommand> --help). "
477
+ epilog="Subcommands: lcode setup | models | doctor | config | bench (lcode <subcommand> --help). "
403
478
  "Docs: https://nasser1941.github.io/lcode/",
404
479
  )
405
480
  parser.add_argument("-p", "--prompt", help="run one request non-interactively and exit")
@@ -437,10 +512,25 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
437
512
  p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
438
513
  p.add_argument("value", nargs="?")
439
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
440
530
  return subs
441
531
 
442
532
 
443
- 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}
444
534
 
445
535
 
446
536
  def main(argv: list[str] | None = None) -> None:
lcode/config.py CHANGED
@@ -37,6 +37,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
37
37
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
38
38
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
39
39
  "searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
40
+ "checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
40
41
  }
41
42
  ENV_OVERRIDES = {
42
43
  "LCODE_MODEL": "model",
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(