lcode-cli 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
lcode/agent.py CHANGED
@@ -19,6 +19,7 @@ from rich.panel import Panel
19
19
  from rich.text import Text
20
20
 
21
21
  from lcode import catalog, limits, sessions, web
22
+ from lcode.checkpoints import Checkpoints
22
23
  from lcode.config import format_tokens
23
24
  from lcode.ollama import Ollama, OllamaError
24
25
  from lcode.permissions import Permissions
@@ -136,6 +137,7 @@ class Settings:
136
137
  web: str = "on" # on | ask | off
137
138
  search_backend: str = "auto"
138
139
  searxng_url: str | None = None
140
+ checkpoints: bool = True # snapshot files before the model changes them, for /undo
139
141
 
140
142
 
141
143
  class Agent:
@@ -147,6 +149,7 @@ class Agent:
147
149
  self.perms = Permissions(self.console, settings.permission_mode)
148
150
  self.tools = Toolbox(self)
149
151
  self.session_id = self.new_session_id()
152
+ self.checkpoints = Checkpoints(self.console, settings.checkpoints)
150
153
  self.session_name = ""
151
154
  self.session_title = ""
152
155
  self.ctx_used = 0
@@ -220,6 +223,7 @@ class Agent:
220
223
  "name": self.session_name,
221
224
  "title": self.session_title,
222
225
  "messages": self.messages,
226
+ "checkpoints": self.checkpoints.to_json(),
223
227
  }
224
228
  f.write_text(json.dumps(data))
225
229
 
@@ -228,6 +232,7 @@ class Agent:
228
232
  self.session_id = self.new_session_id()
229
233
  self.session_name = ""
230
234
  self.session_title = ""
235
+ self.checkpoints.reset(self.session_id)
231
236
 
232
237
  def rename(self, name: str) -> None:
233
238
  self.session_name = " ".join(name.split())
@@ -249,6 +254,7 @@ class Agent:
249
254
  note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
250
255
  self.messages = [{"role": "system", "content": self.system_prompt()}, *data.get("messages", [])[1:]]
251
256
  self.session_id = info.id
257
+ self.checkpoints.load(info.id, data.get("checkpoints") or [])
252
258
  self.session_name = data.get("name", "")
253
259
  self.session_title = data.get("title") or sessions.title_from(self.messages)
254
260
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
@@ -406,6 +412,17 @@ class Agent:
406
412
  return name
407
413
 
408
414
  def run_turn(self, user_text: str) -> None:
415
+ self.checkpoints.begin_turn(self.session_id, user_text, len(self.messages))
416
+ try:
417
+ self._run_turn(user_text)
418
+ finally:
419
+ self.checkpoints.end_turn()
420
+
421
+ def checkpoint(self) -> None:
422
+ """Called before the model changes files: snapshot them once per request, for /undo."""
423
+ self.checkpoints.before_change(self.cwd)
424
+
425
+ def _run_turn(self, user_text: str) -> None:
409
426
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text)})
410
427
  malformed = 0
411
428
  for _ in range(MAX_STEPS_PER_TURN):
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
@@ -390,6 +390,7 @@ def cmd_chat(args) -> None:
390
390
  web="off" if args.no_web else cfg["web"],
391
391
  search_backend=cfg["search_backend"],
392
392
  searxng_url=cfg["searxng_url"],
393
+ checkpoints=cfg["checkpoints"],
393
394
  )
394
395
  agent = Agent(ollama, settings, cwd, console=console)
395
396
  repl(agent, prompt=args.prompt, hardware=hw, cont=args.cont, resume=args.resume)
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/repl.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import html
6
+ import json
6
7
  import os
7
8
  import re
8
9
  import threading
@@ -23,6 +24,7 @@ from rich.text import Text
23
24
  from lcode import __version__, catalog, limits, sessions, web
24
25
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
25
26
  from lcode.catalog import MIN_USEFUL_CONTEXT
27
+ from lcode.checkpoints import Checkpoint, CheckpointError, Restore
26
28
  from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
27
29
  from lcode.hardware import Hardware
28
30
  from lcode.ollama import OllamaError
@@ -34,6 +36,9 @@ COMMANDS = {
34
36
  "/clear": "Start a fresh conversation",
35
37
  "/rename": "Name this session so you can find it later, e.g. /rename auth refactor",
36
38
  "/resume": "Resume a saved session: pick from a list, or /resume <number|name> (/resume all: every folder)",
39
+ "/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
40
+ "/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
41
+ "/checkpoints": "List the requests that changed files, and which files",
37
42
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
38
43
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
39
44
  "/ctx": "Shortcut for /context",
@@ -229,6 +234,157 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
229
234
  apply_context(agent, size, hardware)
230
235
 
231
236
 
237
+ def ask_yes(question: str) -> bool:
238
+ try:
239
+ return input(f" {question} [y/N] ").strip().lower() in ("y", "yes")
240
+ except EOFError:
241
+ return False
242
+
243
+
244
+ def checkpoints_off(agent: Agent) -> bool:
245
+ if agent.checkpoints.enabled:
246
+ return False
247
+ agent.console.print("Checkpoints are off. Turn them on with: lcode config set checkpoints true")
248
+ return True
249
+
250
+
251
+ def print_checkpoints(agent: Agent) -> None:
252
+ c = agent.console
253
+ if checkpoints_off(agent):
254
+ return
255
+ items = agent.checkpoints.items
256
+ if not items:
257
+ c.print(
258
+ "No checkpoints yet. Before the model first changes files in a request, lcode saves a checkpoint; "
259
+ "/undo restores it."
260
+ )
261
+ return
262
+ table = Table(title="Checkpoints", title_justify="left", header_style="bold")
263
+ table.add_column("#", justify="right", style="cyan")
264
+ table.add_column("When")
265
+ table.add_column("Request")
266
+ table.add_column("Files changed")
267
+ for cp in items:
268
+ files = ", ".join(escape(p) for p in cp.paths[:3]) + (f", +{len(cp.paths) - 3}" if len(cp.paths) > 3 else "")
269
+ style = "dim strike" if cp.undone else ""
270
+ table.add_row(
271
+ str(cp.n),
272
+ sessions.age(cp.created),
273
+ escape(cp.title),
274
+ files + (" [dim](undone)[/]" if cp.undone else ""),
275
+ style=style,
276
+ )
277
+ c.print(table)
278
+ c.print("[dim]/undo reverts the latest request; /rewind <number> goes back to before that request.[/]")
279
+
280
+
281
+ def undo_command(agent: Agent) -> None:
282
+ if checkpoints_off(agent):
283
+ return
284
+ active = agent.checkpoints.active()
285
+ if not active:
286
+ agent.console.print("Nothing to undo: no request in this session has changed files (or they're all undone).")
287
+ return
288
+ restore_checkpoint(agent, active[-1], rewind=False)
289
+
290
+
291
+ def rewind_command(agent: Agent, arg: str) -> None:
292
+ c = agent.console
293
+ if checkpoints_off(agent):
294
+ return
295
+ if not arg:
296
+ print_checkpoints(agent)
297
+ if not agent.checkpoints.active():
298
+ return
299
+ try:
300
+ arg = input(" Go back to before which request? (number, Enter to cancel): ").strip()
301
+ except EOFError:
302
+ return
303
+ if not arg:
304
+ return
305
+ target = agent.checkpoints.find(int(arg)) if arg.isdigit() else None
306
+ if target is None:
307
+ c.print(f"[yellow]No checkpoint {escape(arg)}. /checkpoints lists them.[/]")
308
+ elif target.undone:
309
+ c.print(f"Checkpoint {target.n} is already undone.")
310
+ else:
311
+ restore_checkpoint(agent, target, rewind=True)
312
+
313
+
314
+ def file_summary(plan: Restore, limit: int = 30) -> str:
315
+ parts = []
316
+ for verb, paths in (("restored", plan.restore), ("removed", plan.remove)):
317
+ if paths:
318
+ shown = ", ".join(paths[:limit]) + (f" and {len(paths) - limit} more" if len(paths) > limit else "")
319
+ parts.append(f"{verb} {shown}")
320
+ return "; ".join(parts)
321
+
322
+
323
+ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
324
+ """Put the files back to how they were before `target`'s request (and every later one)."""
325
+ c, cps = agent.console, agent.checkpoints
326
+ try:
327
+ plan = cps.plan(target)
328
+ except CheckpointError as e:
329
+ c.print(f"[red]Can't read checkpoint {target.n}: {escape(str(e))}[/]")
330
+ return
331
+ later = len(plan.undoing) - 1
332
+ also = f" and {later} later request{'' if later == 1 else 's'}" if later > 0 else ""
333
+ action = "Going back to before" if rewind else "Undoing"
334
+ c.print(f"{action} request {target.n}{also}: [bold]{escape(target.title)}[/]")
335
+ for path in plan.restore[:20]:
336
+ c.print(f" [green]restore[/] {escape(path)}")
337
+ for path in plan.remove[:20]:
338
+ c.print(f" [red]remove[/] {escape(path)}")
339
+ hidden = max(0, len(plan.restore) - 20) + max(0, len(plan.remove) - 20)
340
+ if hidden:
341
+ c.print(f" … and {hidden} more")
342
+ if plan.conflicts:
343
+ c.print("[yellow]These files changed again after lcode changed them; going back loses those later edits:[/]")
344
+ for path in plan.conflicts[:20]:
345
+ c.print(f" [yellow]{escape(path)}[/]")
346
+ if not ask_yes("Go back anyway?"):
347
+ c.print("Nothing changed.")
348
+ return
349
+ elif rewind and (plan.restore or plan.remove) and not ask_yes("Restore these files?"):
350
+ c.print("Nothing changed.")
351
+ return
352
+ index = target.message_index
353
+ truncate = (
354
+ rewind
355
+ and 0 < index < len(agent.messages)
356
+ and agent.messages[index].get("role") == "user"
357
+ and agent.messages[index].get("content", "").startswith(target.request)
358
+ and ask_yes("Also remove those requests and answers from the conversation?")
359
+ )
360
+ try:
361
+ cps.apply(plan)
362
+ except CheckpointError as e:
363
+ c.print(f"[red]Couldn't restore the files: {escape(str(e))}[/]")
364
+ return
365
+ for path in cps.absolute_paths(plan):
366
+ agent.tools.read_mtimes.pop(path, None) # the model must read restored files again before editing
367
+ if not (plan.restore or plan.remove):
368
+ c.print("The files were already back to how they were before that request.")
369
+ else:
370
+ c.print(f"[green]Done:[/] {escape(file_summary(plan, limit=8))}.")
371
+ if truncate:
372
+ agent.messages = agent.messages[:index]
373
+ agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
374
+ c.print(f"The conversation is back to before request {target.n} as well.")
375
+ elif plan.restore or plan.remove:
376
+ what = "undid the file changes from" if not rewind else "rolled the files back to before"
377
+ agent.messages.append(
378
+ {
379
+ "role": "user",
380
+ "content": f'[lcode] The user {what} their request "{target.title}": {file_summary(plan)}. '
381
+ "Those files are back to how they were before that request, so read them again before "
382
+ "changing them.",
383
+ }
384
+ )
385
+ agent.save()
386
+
387
+
232
388
  def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
233
389
  c, s = agent.console, agent.settings
234
390
  if size == s.context:
@@ -282,7 +438,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
282
438
  if cmd in ("/exit", "/quit", "/q"):
283
439
  return False
284
440
  if cmd == "/help":
285
- rows = "\n".join(f" [cyan]{k:<10}[/] {v}" for k, v in COMMANDS.items())
441
+ rows = "\n".join(f" [cyan]{k:<13}[/] {v}" for k, v in COMMANDS.items())
286
442
  keys = (
287
443
  "Enter = send · Esc+Enter or trailing \\ = newline · Ctrl+C = interrupt · Ctrl+D = quit · "
288
444
  "@path = attach a file · Shift+Tab = cycle permission mode"
@@ -302,6 +458,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
302
458
  info = choose_session(agent, arg)
303
459
  if info:
304
460
  resume_session(agent, info)
461
+ elif cmd == "/undo":
462
+ undo_command(agent)
463
+ elif cmd == "/rewind":
464
+ rewind_command(agent, arg)
465
+ elif cmd == "/checkpoints":
466
+ print_checkpoints(agent)
305
467
  elif cmd == "/compact":
306
468
  agent.compact(arg)
307
469
  elif cmd in ("/context", "/ctx"):
lcode/tools.py CHANGED
@@ -438,6 +438,7 @@ class Toolbox:
438
438
  ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body)
439
439
  if not ok:
440
440
  return feedback
441
+ self.agent.checkpoint()
441
442
  self._write(p, content)
442
443
  n = len(content.splitlines())
443
444
  self.console.print(f" [green]✓[/] {'Updated' if exists else 'Created'} {self.rel(p)} ({n} lines)")
@@ -472,6 +473,7 @@ class Toolbox:
472
473
  ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", self._diff(p, text, new_text))
473
474
  if not ok:
474
475
  return feedback
476
+ self.agent.checkpoint()
475
477
  self._write(p, new_text)
476
478
  self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
477
479
  idx = new_text.find(new_string) if new_string else -1
@@ -493,6 +495,7 @@ class Toolbox:
493
495
  )
494
496
  if not ok:
495
497
  return feedback
498
+ self.agent.checkpoint()
496
499
  self.console.print(Text(f" $ {command}", style="bold cyan"))
497
500
  fd, cwd_file = tempfile.mkstemp(prefix="lcode_cwd_")
498
501
  os.close(fd)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
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/
@@ -76,6 +76,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
76
76
  largest context that fits: `lcode setup` does it in one step.
77
77
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
78
78
  your approval. `auto-edit` and `yolo` modes when you want speed.
79
+ - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
80
+ request's edits, new files and shell-command changes, without touching your git history.
79
81
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
80
82
  with tool calling.
81
83
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
@@ -123,6 +125,7 @@ lcode
123
125
  | `lcode -c` / `lcode --resume` | continue the last session here / pick a saved session from a list |
124
126
  | `lcode --model qwen3.5-9b --context 128k` | pick a model and context window for this session |
125
127
  | `lcode models` / `lcode doctor` | what fits this machine / check the installation |
128
+ | `/undo`, `/rewind` | take back the last request's file changes, or go back further |
126
129
  | `/rename`, `/resume` | name the current session, resume a saved one |
127
130
  | `/model`, `/context`, `/compact`, `/help` | switch model, resize the context window (pick from a list), summarize, list commands |
128
131
 
@@ -167,7 +170,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
167
170
 
168
171
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
169
172
 
170
- - [`/undo` and automatic checkpoints](https://github.com/nasser1941/lcode/issues/4)
171
173
  - [`lcode bench`: score models on your own hardware](https://github.com/nasser1941/lcode/issues/5)
172
174
  - [MCP tool servers](https://github.com/nasser1941/lcode/issues/6)
173
175
  - [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
@@ -0,0 +1,22 @@
1
+ lcode/__init__.py,sha256=6o9drjzBZ6xhvmNDRH_G_fyZUs1QniLoDVSjRcEmymU,117
2
+ lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
+ lcode/agent.py,sha256=NtLV_QI75Ixvs-6Ig39qhMb2kPuIkK4z1qMclyBb3sQ,25335
4
+ lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
5
+ lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
6
+ lcode/cli.py,sha256=rU5XYsYmnUU-N9N1GX_s0oWSwgqYM-8k_TEKR0tlzvs,20143
7
+ lcode/config.py,sha256=xkD7L5gSrHbgnXt7C8SYktNXO62BqNIXtuGyCY-y4FY,6002
8
+ lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
9
+ lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
10
+ lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
11
+ lcode/ollama.py,sha256=fELwqw7xqLH9TM7XvdI1pxqYZFeIRJBv-VSz9REdvwA,6516
12
+ lcode/permissions.py,sha256=wJKwhJQx44AzjOFZCWwM4M2oUlPFUNYI2cIVfoBGGgc,3898
13
+ lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
14
+ lcode/repl.py,sha256=F5DsLYya5UmJt2LvJf-N_3CdBKxiK8sr7B0zNSS7gKI,26525
15
+ lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
16
+ lcode/tools.py,sha256=0JFM-owT3K243tvMhHlcfWKSMyHSa44GY4A2GebKU0k,25935
17
+ lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
18
+ lcode_cli-0.3.0.dist-info/METADATA,sha256=o6_TdM5_SW2xs3j5SyjtNE6iIfagy9ivcBS29xplJ_c,9423
19
+ lcode_cli-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
20
+ lcode_cli-0.3.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
21
+ lcode_cli-0.3.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
22
+ lcode_cli-0.3.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- lcode/__init__.py,sha256=f69CJWRNbXrayd_uWS82hWDQJcvsIH6fRtjvpXj-I8s,117
2
- lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=ppO9sMxymIVvFPeHS-5hqTyryQYBOK_0OAucaX6SeWU,24535
4
- lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
5
- lcode/cli.py,sha256=bvKv_5SGJgy8bkY-zFpXwyNACiGa6nu_n16ems_Vmq8,20103
6
- lcode/config.py,sha256=ZyddgIZ9TaHAcJSW_A8o7qFQbp1s2RHmsbFzOzEjV4o,5894
7
- lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
8
- lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
9
- lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
10
- lcode/ollama.py,sha256=fELwqw7xqLH9TM7XvdI1pxqYZFeIRJBv-VSz9REdvwA,6516
11
- lcode/permissions.py,sha256=wJKwhJQx44AzjOFZCWwM4M2oUlPFUNYI2cIVfoBGGgc,3898
12
- lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
13
- lcode/repl.py,sha256=1InDmkMz286vuCOwRHNCTdBih_KekYkM4Ol52_g7C4s,20001
14
- lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
15
- lcode/tools.py,sha256=97wPiabQuztMr4qsMgf_beNAFfraxSlU2195QJffFkg,25835
16
- lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
17
- lcode_cli-0.2.2.dist-info/METADATA,sha256=XQ3SNVJtZ6MAxIM90xpJVSLQ17uCvulOJ00kyhSNbco,9232
18
- lcode_cli-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
- lcode_cli-0.2.2.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
20
- lcode_cli-0.2.2.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
21
- lcode_cli-0.2.2.dist-info/RECORD,,