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/__init__.py +1 -1
- lcode/agent.py +23 -0
- lcode/bench.py +1082 -0
- lcode/checkpoints.py +449 -0
- lcode/cli.py +92 -2
- lcode/config.py +1 -0
- lcode/ollama.py +4 -0
- lcode/repl.py +163 -1
- lcode/tools.py +3 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.1.dist-info}/METADATA +7 -4
- lcode_cli-0.3.1.dist-info/RECORD +23 -0
- lcode_cli-0.2.2.dist-info/RECORD +0 -21
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.1.dist-info}/licenses/LICENSE +0 -0
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(
|