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/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.1
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -73,9 +73,12 @@ through [Ollama](https://ollama.com), and when it needs current information it c
73
73
  plans multi-step tasks with a visible todo list.
74
74
  - **Long context.** 256K tokens with the default model (1M with Nemotron). You choose the window.
75
75
  - **Hardware-aware.** Detects your NVIDIA GPU or Apple Silicon Mac and picks the best model and the
76
- largest context that fits: `lcode setup` does it in one step.
76
+ largest context that fits: `lcode setup` does it in one step. `lcode bench` compares models on
77
+ real coding tasks on your machine.
77
78
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
78
79
  your approval. `auto-edit` and `yolo` modes when you want speed.
80
+ - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
81
+ request's edits, new files and shell-command changes, without touching your git history.
79
82
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
80
83
  with tool calling.
81
84
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
@@ -123,6 +126,8 @@ lcode
123
126
  | `lcode -c` / `lcode --resume` | continue the last session here / pick a saved session from a list |
124
127
  | `lcode --model qwen3.5-9b --context 128k` | pick a model and context window for this session |
125
128
  | `lcode models` / `lcode doctor` | what fits this machine / check the installation |
129
+ | `lcode bench qwen3.6-35b qwen3.5-9b` | compare models on small coding tasks on this machine |
130
+ | `/undo`, `/rewind` | take back the last request's file changes, or go back further |
126
131
  | `/rename`, `/resume` | name the current session, resume a saved one |
127
132
  | `/model`, `/context`, `/compact`, `/help` | switch model, resize the context window (pick from a list), summarize, list commands |
128
133
 
@@ -167,8 +172,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
167
172
 
168
173
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
169
174
 
170
- - [`/undo` and automatic checkpoints](https://github.com/nasser1941/lcode/issues/4)
171
- - [`lcode bench`: score models on your own hardware](https://github.com/nasser1941/lcode/issues/5)
172
175
  - [MCP tool servers](https://github.com/nasser1941/lcode/issues/6)
173
176
  - [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
174
177
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
@@ -0,0 +1,23 @@
1
+ lcode/__init__.py,sha256=7co3jkJP_ri78Ap0JVyQJkT8sWh6fKXdJUCuT5A5Ab4,117
2
+ lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
+ lcode/agent.py,sha256=VYYGmkQi8CnP7mXgA2n_tjXnYn44uwK00j4Ow7vGKmg,25776
4
+ lcode/bench.py,sha256=72TSBit063ERFlzgsq2xuCSzWaCLGoZKhB3G_E5HlBM,37751
5
+ lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
6
+ lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
7
+ lcode/cli.py,sha256=h830l_4BTgQCZULcKbMvKMV57v5g6Va0livr83vAF1E,24295
8
+ lcode/config.py,sha256=xkD7L5gSrHbgnXt7C8SYktNXO62BqNIXtuGyCY-y4FY,6002
9
+ lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
10
+ lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
11
+ lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
12
+ lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
13
+ lcode/permissions.py,sha256=wJKwhJQx44AzjOFZCWwM4M2oUlPFUNYI2cIVfoBGGgc,3898
14
+ lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
15
+ lcode/repl.py,sha256=F5DsLYya5UmJt2LvJf-N_3CdBKxiK8sr7B0zNSS7gKI,26525
16
+ lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
17
+ lcode/tools.py,sha256=0JFM-owT3K243tvMhHlcfWKSMyHSa44GY4A2GebKU0k,25935
18
+ lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
19
+ lcode_cli-0.3.1.dist-info/METADATA,sha256=8sici8niZzAGQwTLMDDsOckVIbZmYB3lXZ8Cqpn7F2o,9490
20
+ lcode_cli-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
21
+ lcode_cli-0.3.1.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
22
+ lcode_cli-0.3.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
23
+ lcode_cli-0.3.1.dist-info/RECORD,,
@@ -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,,