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 +1 -1
- lcode/agent.py +17 -0
- lcode/checkpoints.py +449 -0
- lcode/cli.py +1 -0
- lcode/config.py +1 -0
- lcode/repl.py +163 -1
- lcode/tools.py +3 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.0.dist-info}/METADATA +4 -2
- lcode_cli-0.3.0.dist-info/RECORD +22 -0
- lcode_cli-0.2.2.dist-info/RECORD +0 -21
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.2.2.dist-info → lcode_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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:<
|
|
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.
|
|
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,,
|
lcode_cli-0.2.2.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|