lcode-cli 0.19.0__py3-none-any.whl → 0.21.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/acp.py +1 -1
- lcode/agent.py +28 -1
- lcode/api.py +6 -2
- lcode/cli.py +17 -1
- lcode/config.py +2 -0
- lcode/jobs.py +165 -0
- lcode/notify.py +43 -0
- lcode/planning.py +3 -1
- lcode/repl.py +26 -3
- lcode/subagents.py +1 -0
- lcode/tools.py +80 -3
- lcode/update.py +557 -0
- {lcode_cli-0.19.0.dist-info → lcode_cli-0.21.0.dist-info}/METADATA +1 -2
- {lcode_cli-0.19.0.dist-info → lcode_cli-0.21.0.dist-info}/RECORD +18 -15
- {lcode_cli-0.19.0.dist-info → lcode_cli-0.21.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.19.0.dist-info → lcode_cli-0.21.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.19.0.dist-info → lcode_cli-0.21.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/acp.py
CHANGED
|
@@ -46,7 +46,7 @@ KINDS = {
|
|
|
46
46
|
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
|
|
47
47
|
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
|
|
48
48
|
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
|
-
"present_plan": "switch_mode",
|
|
49
|
+
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
|
|
50
50
|
} # fmt: skip
|
|
51
51
|
STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
|
|
52
52
|
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
|
lcode/agent.py
CHANGED
|
@@ -22,11 +22,12 @@ from rich.markup import escape
|
|
|
22
22
|
from rich.panel import Panel
|
|
23
23
|
from rich.text import Text
|
|
24
24
|
|
|
25
|
-
from lcode import catalog, codesearch, extensions, limits, planning, repomap, sessions, subagents, vision, web
|
|
25
|
+
from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
|
|
26
26
|
from lcode import context as context_tools
|
|
27
27
|
from lcode import memory as memory_notes
|
|
28
28
|
from lcode.checkpoints import Checkpoints
|
|
29
29
|
from lcode.config import format_tokens
|
|
30
|
+
from lcode.jobs import Jobs
|
|
30
31
|
from lcode.mcp import McpManager
|
|
31
32
|
from lcode.ollama import Ollama, OllamaError
|
|
32
33
|
from lcode.permissions import Permissions
|
|
@@ -212,6 +213,8 @@ class Settings:
|
|
|
212
213
|
lsp: str = "off" # auto: use the installed language servers (lcode.lsp)
|
|
213
214
|
repo_map: bool = False # the repository map (lcode.repomap)
|
|
214
215
|
embed_model: str = "off" # semantic code search (lcode.codesearch): auto, off or a model
|
|
216
|
+
notify: bool = False # desktop notifications after long requests (lcode.notify)
|
|
217
|
+
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
215
218
|
max_parallel_agents: int = 1
|
|
216
219
|
|
|
217
220
|
|
|
@@ -256,6 +259,8 @@ class Agent:
|
|
|
256
259
|
self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
|
|
257
260
|
self.cancel: threading.Event | None = None # set from another thread to stop
|
|
258
261
|
self.on_tool = None # called with (name, arguments) before each tool runs
|
|
262
|
+
self.jobs = Jobs() # background commands (shared with subagents)
|
|
263
|
+
self.turn_started = 0.0 # when the running request started (time.monotonic)
|
|
259
264
|
self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json
|
|
260
265
|
self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams
|
|
261
266
|
self.current_call = "" # id of the tool call that's running, so a permission request can name it
|
|
@@ -746,6 +751,7 @@ class Agent:
|
|
|
746
751
|
return name
|
|
747
752
|
|
|
748
753
|
def run_turn(self, user_text: str) -> None:
|
|
754
|
+
self.turn_started = time.monotonic()
|
|
749
755
|
self.checkpoints.begin_turn(self.session_id, user_text, len(self.messages))
|
|
750
756
|
try:
|
|
751
757
|
self._run_turn(user_text)
|
|
@@ -760,6 +766,27 @@ class Agent:
|
|
|
760
766
|
Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
|
|
761
767
|
)
|
|
762
768
|
|
|
769
|
+
def waiting(self, title: str) -> None:
|
|
770
|
+
"""lcode waits for the user's answer: tell them, if they've likely walked away (lcode.notify)."""
|
|
771
|
+
if self.hooks.for_event("notification"):
|
|
772
|
+
self.hooks.notify(f"lcode needs you: {title}", self.cwd)
|
|
773
|
+
if (
|
|
774
|
+
self.settings.notify
|
|
775
|
+
and self.interactive
|
|
776
|
+
and time.monotonic() - self.turn_started >= self.settings.notify_after
|
|
777
|
+
):
|
|
778
|
+
notify.desktop("lcode needs you", title)
|
|
779
|
+
|
|
780
|
+
def finished(self, request: str, seconds: float) -> None:
|
|
781
|
+
"""A request is done: after a long one, tell the user."""
|
|
782
|
+
if seconds < self.settings.notify_after:
|
|
783
|
+
return
|
|
784
|
+
title = sessions.title_from([{"role": "user", "content": request}])
|
|
785
|
+
if self.hooks.for_event("notification"):
|
|
786
|
+
self.hooks.notify(f"lcode finished: {title}", self.cwd)
|
|
787
|
+
if self.settings.notify and self.interactive:
|
|
788
|
+
notify.desktop("lcode is done", f"{title} ({seconds:.0f}s)")
|
|
789
|
+
|
|
763
790
|
def sandbox_root(self) -> Path | None:
|
|
764
791
|
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
|
765
792
|
if not self.sandbox:
|
lcode/api.py
CHANGED
|
@@ -192,6 +192,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
192
192
|
prune=cfg["prune"],
|
|
193
193
|
repo_map=cfg["repo_map"],
|
|
194
194
|
embed_model=cfg["embed_model"],
|
|
195
|
+
notify=cfg["notify"],
|
|
196
|
+
notify_after=cfg["notify_after"],
|
|
195
197
|
)
|
|
196
198
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
197
199
|
agent.interactive = options.interactive
|
|
@@ -206,8 +208,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
206
208
|
agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project)
|
|
207
209
|
for problem in agent.hooks.problems:
|
|
208
210
|
console.print(f"[yellow]Settings: {problem}[/]")
|
|
209
|
-
|
|
210
|
-
agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd)
|
|
211
|
+
agent.perms.on_prompt = agent.waiting # notification hooks and desktop notifications
|
|
211
212
|
if cfg["lsp"] == "auto":
|
|
212
213
|
from lcode import lsp
|
|
213
214
|
from lcode.checkpoints import work_tree_for
|
|
@@ -233,6 +234,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
233
234
|
|
|
234
235
|
|
|
235
236
|
def close_agent(agent: Agent) -> None:
|
|
237
|
+
stopped = agent.jobs.stop_all()
|
|
238
|
+
if stopped:
|
|
239
|
+
agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]")
|
|
236
240
|
if agent.lsp is not None:
|
|
237
241
|
agent.lsp.close()
|
|
238
242
|
if agent.mcp:
|
lcode/cli.py
CHANGED
|
@@ -678,7 +678,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
678
678
|
parser = argparse.ArgumentParser(
|
|
679
679
|
prog="lcode",
|
|
680
680
|
description="A local-first terminal coding agent powered by open-weight models via Ollama.",
|
|
681
|
-
epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index "
|
|
681
|
+
epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index | update "
|
|
682
682
|
"(lcode <subcommand> --help). "
|
|
683
683
|
"Docs: https://nasser1941.github.io/lcode/",
|
|
684
684
|
)
|
|
@@ -774,6 +774,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
774
774
|
from lcode.mcp.commands import build_parser as mcp_parser
|
|
775
775
|
|
|
776
776
|
subs["mcp"] = mcp_parser()
|
|
777
|
+
p = argparse.ArgumentParser(
|
|
778
|
+
prog="lcode update",
|
|
779
|
+
description="Update lcode, and Ollama when it runs in Docker (to the newest version on Docker Hub, pinned "
|
|
780
|
+
"to its version tag).",
|
|
781
|
+
)
|
|
782
|
+
p.add_argument("what", nargs="?", choices=["lcode", "ollama"], help="only this one (default: both)")
|
|
783
|
+
p.add_argument("--check", action="store_true", help="only show which versions are out")
|
|
784
|
+
p.add_argument("-y", "--yes", action="store_true", help="don't ask before updating")
|
|
785
|
+
subs["update"] = p
|
|
777
786
|
subs["acp"] = argparse.ArgumentParser(
|
|
778
787
|
prog="lcode acp",
|
|
779
788
|
description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout "
|
|
@@ -787,6 +796,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
787
796
|
return subs
|
|
788
797
|
|
|
789
798
|
|
|
799
|
+
def cmd_update(args) -> None:
|
|
800
|
+
from lcode import update
|
|
801
|
+
|
|
802
|
+
sys.exit(update.command(console, args.what or "", check=args.check, yes=args.yes))
|
|
803
|
+
|
|
804
|
+
|
|
790
805
|
def cmd_acp(args) -> None:
|
|
791
806
|
from lcode import acp
|
|
792
807
|
|
|
@@ -863,6 +878,7 @@ COMMANDS = {
|
|
|
863
878
|
"index": cmd_index,
|
|
864
879
|
"action": cmd_action,
|
|
865
880
|
"acp": cmd_acp,
|
|
881
|
+
"update": cmd_update,
|
|
866
882
|
}
|
|
867
883
|
|
|
868
884
|
|
lcode/config.py
CHANGED
|
@@ -57,6 +57,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
57
57
|
"lsp": ("auto", str, "language servers for code navigation and errors after edits: auto | off"),
|
|
58
58
|
"prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"),
|
|
59
59
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
60
|
+
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
|
+
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
60
62
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
61
63
|
}
|
|
62
64
|
ENV_OVERRIDES = {
|
lcode/jobs.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Background commands: dev servers, watchers and other processes that keep running.
|
|
2
|
+
|
|
3
|
+
The model starts one with `bash(command, background=true)` and gets an id back, reads what it
|
|
4
|
+
printed since last time with `bash_output(id)` and stops it with `bash_stop(id)`. `/jobs` lists
|
|
5
|
+
them. They're all stopped when the session ends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
KEEP = 1_000_000 # characters of a job's output kept in memory
|
|
21
|
+
STARTUP_WAIT = 3.0 # seconds to wait for a new job's first output (and quick failures)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JobError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Job:
|
|
30
|
+
id: str
|
|
31
|
+
command: str
|
|
32
|
+
proc: subprocess.Popen
|
|
33
|
+
kill: Callable[[], None] | None = None # extra cleanup, e.g. the process in the sandbox
|
|
34
|
+
started: float = field(default_factory=time.monotonic)
|
|
35
|
+
output: str = ""
|
|
36
|
+
read: int = 0 # how much of `output` the model has seen
|
|
37
|
+
dropped: int = 0 # characters dropped from the front to stay under KEEP
|
|
38
|
+
stopped: bool = False
|
|
39
|
+
ended: float = 0.0
|
|
40
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
41
|
+
|
|
42
|
+
def pump(self) -> None:
|
|
43
|
+
assert self.proc.stdout is not None
|
|
44
|
+
for line in self.proc.stdout:
|
|
45
|
+
with self.lock:
|
|
46
|
+
self.output += line
|
|
47
|
+
if len(self.output) > KEEP:
|
|
48
|
+
cut = len(self.output) - KEEP
|
|
49
|
+
self.output = self.output[cut:]
|
|
50
|
+
self.dropped += cut
|
|
51
|
+
self.read = max(0, self.read - cut)
|
|
52
|
+
self.proc.wait()
|
|
53
|
+
self.ended = time.monotonic()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def running(self) -> bool:
|
|
57
|
+
return self.proc.poll() is None
|
|
58
|
+
|
|
59
|
+
def status(self) -> str:
|
|
60
|
+
if self.running:
|
|
61
|
+
return "running"
|
|
62
|
+
if self.stopped:
|
|
63
|
+
return "stopped"
|
|
64
|
+
return f"exited with code {self.proc.returncode}"
|
|
65
|
+
|
|
66
|
+
def runtime(self) -> float:
|
|
67
|
+
return (self.ended or time.monotonic()) - self.started
|
|
68
|
+
|
|
69
|
+
def new_output(self, limit: int) -> str:
|
|
70
|
+
with self.lock:
|
|
71
|
+
text, self.read = self.output[self.read :], len(self.output)
|
|
72
|
+
if len(text) > limit:
|
|
73
|
+
text = f"[… {len(text) - limit:,} earlier characters not shown]\n" + text[-limit:]
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Jobs:
|
|
78
|
+
"""The session's background commands (shared with its subagents)."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self.items: dict[str, Job] = {}
|
|
82
|
+
self.counter = 0
|
|
83
|
+
|
|
84
|
+
def start(self, argv: list[str], cwd: Path, command: str, kill: Callable[[], None] | None = None) -> Job:
|
|
85
|
+
proc = subprocess.Popen(
|
|
86
|
+
argv,
|
|
87
|
+
cwd=cwd,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.STDOUT,
|
|
90
|
+
stdin=subprocess.DEVNULL,
|
|
91
|
+
text=True,
|
|
92
|
+
errors="replace",
|
|
93
|
+
start_new_session=True, # its own process group, so stopping it stops its children too
|
|
94
|
+
bufsize=1,
|
|
95
|
+
)
|
|
96
|
+
self.counter += 1
|
|
97
|
+
job = Job(str(self.counter), command, proc, kill)
|
|
98
|
+
self.items[job.id] = job
|
|
99
|
+
threading.Thread(target=job.pump, daemon=True).start()
|
|
100
|
+
deadline = time.monotonic() + STARTUP_WAIT
|
|
101
|
+
while time.monotonic() < deadline and job.running and not job.output:
|
|
102
|
+
time.sleep(0.05)
|
|
103
|
+
if job.running:
|
|
104
|
+
time.sleep(min(0.5, max(0.0, deadline - time.monotonic()))) # a little more of its first output
|
|
105
|
+
else:
|
|
106
|
+
time.sleep(0.1) # let the pump read the rest
|
|
107
|
+
return job
|
|
108
|
+
|
|
109
|
+
def get(self, job_id: str) -> Job:
|
|
110
|
+
job = self.items.get(str(job_id).strip().lstrip("#"))
|
|
111
|
+
if job is None:
|
|
112
|
+
known = ", ".join(self.items) or "none"
|
|
113
|
+
raise JobError(f"there's no background job {job_id} (jobs: {known})")
|
|
114
|
+
return job
|
|
115
|
+
|
|
116
|
+
def stop(self, job_id: str) -> Job:
|
|
117
|
+
job = self.get(job_id)
|
|
118
|
+
if job.running:
|
|
119
|
+
job.stopped = True
|
|
120
|
+
terminate(job)
|
|
121
|
+
return job
|
|
122
|
+
|
|
123
|
+
def stop_all(self) -> int:
|
|
124
|
+
running = [j for j in self.items.values() if j.running]
|
|
125
|
+
for job in running:
|
|
126
|
+
job.stopped = True
|
|
127
|
+
terminate(job)
|
|
128
|
+
return len(running)
|
|
129
|
+
|
|
130
|
+
def running(self) -> list[Job]:
|
|
131
|
+
return [j for j in self.items.values() if j.running]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def stop_leftovers(group: int) -> bool:
|
|
135
|
+
"""Stop what a finished command left running in its process group; whether there was anything."""
|
|
136
|
+
try:
|
|
137
|
+
os.killpg(group, 0) # raises when nothing is left
|
|
138
|
+
except (ProcessLookupError, PermissionError):
|
|
139
|
+
return False
|
|
140
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
141
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
142
|
+
os.killpg(group, sig)
|
|
143
|
+
time.sleep(0.3)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def terminate(job: Job, grace: float = 3.0) -> None:
|
|
148
|
+
"""SIGTERM the job's process group, then SIGKILL whatever is left."""
|
|
149
|
+
if job.kill is not None:
|
|
150
|
+
with contextlib.suppress(Exception):
|
|
151
|
+
job.kill()
|
|
152
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
153
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
154
|
+
os.killpg(job.proc.pid, sig)
|
|
155
|
+
try:
|
|
156
|
+
job.proc.wait(timeout=grace)
|
|
157
|
+
return
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def describe(job: Job) -> str:
|
|
163
|
+
minutes, seconds = divmod(int(job.runtime()), 60)
|
|
164
|
+
took = f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s"
|
|
165
|
+
return f"job {job.id} ({job.status()}, {took}): {job.command}"
|
lcode/notify.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Desktop notifications: when a long request is done, or lcode waits for an answer during one.
|
|
2
|
+
|
|
3
|
+
Local models are slow, so people switch to something else while a request runs. lcode shows a
|
|
4
|
+
notification with `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell
|
|
5
|
+
where neither is available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def command(title: str, message: str) -> list[str] | None:
|
|
18
|
+
"""The command that shows a notification on this machine, or None."""
|
|
19
|
+
if platform.system() == "Darwin" and shutil.which("osascript"):
|
|
20
|
+
quoted = message.replace("\\", "\\\\").replace('"', '\\"')
|
|
21
|
+
heading = title.replace("\\", "\\\\").replace('"', '\\"')
|
|
22
|
+
return ["osascript", "-e", f'display notification "{quoted}" with title "{heading}"']
|
|
23
|
+
if shutil.which("notify-send"):
|
|
24
|
+
return ["notify-send", "--app-name=lcode", title, message]
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def desktop(title: str, message: str) -> None:
|
|
29
|
+
"""Show the notification without waiting for it; the terminal bell if there's no way to."""
|
|
30
|
+
argv = command(title, message[:200])
|
|
31
|
+
if argv is None:
|
|
32
|
+
sys.stderr.write("\a")
|
|
33
|
+
sys.stderr.flush()
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
def run() -> None:
|
|
37
|
+
try:
|
|
38
|
+
subprocess.run(argv, capture_output=True, timeout=10)
|
|
39
|
+
except (OSError, subprocess.SubprocessError):
|
|
40
|
+
sys.stderr.write("\a")
|
|
41
|
+
sys.stderr.flush()
|
|
42
|
+
|
|
43
|
+
threading.Thread(target=run, daemon=True).start()
|
lcode/planning.py
CHANGED
|
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
|
|
|
32
32
|
PLAN_MODE_TOOLS = frozenset(
|
|
33
33
|
{
|
|
34
34
|
"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
|
|
35
|
-
"agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code",
|
|
35
|
+
"agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code", "bash_output",
|
|
36
36
|
}
|
|
37
37
|
) # fmt: skip
|
|
38
38
|
|
|
@@ -103,6 +103,8 @@ def present(agent: Agent, title: str, plan: str) -> str:
|
|
|
103
103
|
if not agent.interactive:
|
|
104
104
|
return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
|
|
105
105
|
c.print(CHOICES)
|
|
106
|
+
if agent.perms.on_prompt:
|
|
107
|
+
agent.perms.on_prompt(f"Plan: {title}")
|
|
106
108
|
try:
|
|
107
109
|
answer = input(" > ").strip()
|
|
108
110
|
except EOFError:
|
lcode/repl.py
CHANGED
|
@@ -62,6 +62,7 @@ COMMANDS = {
|
|
|
62
62
|
"/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
|
|
63
63
|
"/cd": "Change the working directory",
|
|
64
64
|
"/todos": "Show the current todo list",
|
|
65
|
+
"/jobs": "Background commands the model started: /jobs, /jobs stop <id>",
|
|
65
66
|
"/commit": "Commit the changes with a message in the repository's style, after you approve it",
|
|
66
67
|
"/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
|
|
67
68
|
"/pr": "Push the branch and open a GitHub pull request, after you approve it: /pr [base] [notes]",
|
|
@@ -776,7 +777,28 @@ def web_status(agent: Agent) -> str:
|
|
|
776
777
|
return f"{agent.settings.web} · {search}"
|
|
777
778
|
|
|
778
779
|
|
|
779
|
-
|
|
780
|
+
def jobs_command(agent: Agent, arg: str) -> None:
|
|
781
|
+
"""List the background commands, or stop one: /jobs stop <id>."""
|
|
782
|
+
from lcode.jobs import JobError, describe
|
|
783
|
+
|
|
784
|
+
c = agent.console
|
|
785
|
+
word, _, job_id = arg.partition(" ")
|
|
786
|
+
if word == "stop":
|
|
787
|
+
try:
|
|
788
|
+
job = agent.jobs.stop(job_id or "?")
|
|
789
|
+
except JobError as e:
|
|
790
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
791
|
+
return
|
|
792
|
+
c.print(f"Stopped {escape(describe(job))}")
|
|
793
|
+
return
|
|
794
|
+
if not agent.jobs.items:
|
|
795
|
+
c.print("No background commands. The model starts them with bash(background=true), e.g. a dev server.")
|
|
796
|
+
return
|
|
797
|
+
for job in agent.jobs.items.values():
|
|
798
|
+
style = "green" if job.running else "dim"
|
|
799
|
+
c.print(f"[{style}]{escape(describe(job))}[/]")
|
|
800
|
+
if agent.jobs.running():
|
|
801
|
+
c.print("[dim]/jobs stop <id> stops one; they all stop when the session ends.[/]")
|
|
780
802
|
|
|
781
803
|
|
|
782
804
|
def git_command(agent: Agent, cmd: str, arg: str) -> None:
|
|
@@ -810,8 +832,7 @@ def run_safely(agent: Agent, text: str) -> None:
|
|
|
810
832
|
agent.console.print(f"[red]{e}[/]")
|
|
811
833
|
finally:
|
|
812
834
|
agent.save()
|
|
813
|
-
|
|
814
|
-
agent.hooks.notify(f"lcode finished: {sessions.title_from([{'role': 'user', 'content': text}])}", agent.cwd)
|
|
835
|
+
agent.finished(text, time.monotonic() - started)
|
|
815
836
|
|
|
816
837
|
|
|
817
838
|
def session_start(agent: Agent) -> None:
|
|
@@ -931,6 +952,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
931
952
|
c.print(f"[red]Not a directory: {p}[/]")
|
|
932
953
|
elif cmd == "/todos":
|
|
933
954
|
agent.tools.show_todos()
|
|
955
|
+
elif cmd == "/jobs":
|
|
956
|
+
jobs_command(agent, arg)
|
|
934
957
|
elif cmd == "/init":
|
|
935
958
|
run_safely(agent, INIT_PROMPT)
|
|
936
959
|
elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
|
lcode/subagents.py
CHANGED
|
@@ -463,6 +463,7 @@ class Run:
|
|
|
463
463
|
child = Agent(parent.ollama, settings, cwd, console=Console(file=io.StringIO(), width=120))
|
|
464
464
|
child.perms = self.relay # type: ignore[assignment]
|
|
465
465
|
child.sandbox = parent.sandbox
|
|
466
|
+
child.jobs = parent.jobs # its background commands belong to the session
|
|
466
467
|
if not self.worktree:
|
|
467
468
|
child.checkpoints = parent.checkpoints # changes in the shared tree are part of the running request
|
|
468
469
|
child.mcp = None
|
lcode/tools.py
CHANGED
|
@@ -25,6 +25,7 @@ from rich.text import Text
|
|
|
25
25
|
|
|
26
26
|
from lcode import web
|
|
27
27
|
from lcode.context import shorten
|
|
28
|
+
from lcode.jobs import JobError, describe, stop_leftovers
|
|
28
29
|
from lcode.permissions import bash_key, is_read_only
|
|
29
30
|
from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
|
|
30
31
|
from lcode.sandbox import SandboxError
|
|
@@ -38,6 +39,7 @@ NO_NETWORK = re.compile(
|
|
|
38
39
|
r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
|
|
39
40
|
)
|
|
40
41
|
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
42
|
+
LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running
|
|
41
43
|
IGNORE_DIRS = {
|
|
42
44
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
43
45
|
".ruff_cache", ".tox", ".idea", ".vscode", "dist", "build", ".next", "target", ".cache", ".gradle",
|
|
@@ -119,13 +121,28 @@ SCHEMAS = [
|
|
|
119
121
|
"bash",
|
|
120
122
|
"Run a shell command with bash in the working directory and return its output and exit code. The "
|
|
121
123
|
"working directory persists between calls (cd works). Use it to run scripts and tests, use git, install "
|
|
122
|
-
"packages, etc. Avoid interactive commands."
|
|
124
|
+
"packages, etc. Avoid interactive commands. For a process that keeps running (a dev server, a watcher), "
|
|
125
|
+
"set background=true and give the plain command, without & or redirecting its output: lcode keeps it "
|
|
126
|
+
"running and keeps its output, you get a job id back and can carry on.",
|
|
123
127
|
{
|
|
124
128
|
"command": {"type": "string"},
|
|
125
129
|
"timeout": {"type": "integer", "description": "Seconds before the command is killed (default 180)"},
|
|
130
|
+
"background": {
|
|
131
|
+
"type": "boolean",
|
|
132
|
+
"description": "Keep it running in the background; read its output with bash_output and stop it "
|
|
133
|
+
"with bash_stop (not with kill)",
|
|
134
|
+
},
|
|
126
135
|
},
|
|
127
136
|
["command"],
|
|
128
137
|
),
|
|
138
|
+
_fn(
|
|
139
|
+
"bash_output",
|
|
140
|
+
"Read what a background command (bash with background=true) printed since you last read it, and whether "
|
|
141
|
+
"it's still running.",
|
|
142
|
+
{"id": {"type": "string", "description": "The job id bash returned"}},
|
|
143
|
+
["id"],
|
|
144
|
+
),
|
|
145
|
+
_fn("bash_stop", "Stop a background command.", {"id": {"type": "string"}}, ["id"]),
|
|
129
146
|
_fn(
|
|
130
147
|
"todo_write",
|
|
131
148
|
"Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
|
|
@@ -792,7 +809,7 @@ class Toolbox:
|
|
|
792
809
|
return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}" + problems
|
|
793
810
|
|
|
794
811
|
# -- shell
|
|
795
|
-
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
812
|
+
def t_bash(self, command: str, timeout: int = 180, background: bool = False) -> str:
|
|
796
813
|
sandbox = self.agent.sandbox
|
|
797
814
|
if self.agent.planning() and not is_read_only(command):
|
|
798
815
|
raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
|
|
@@ -814,7 +831,9 @@ class Toolbox:
|
|
|
814
831
|
return feedback
|
|
815
832
|
if not is_read_only(command):
|
|
816
833
|
self.agent.checkpoint()
|
|
817
|
-
self.console.print(Text(f" $ {command}", style="bold cyan"))
|
|
834
|
+
self.console.print(Text(f" $ {command}" + (" (in the background)" if background else ""), style="bold cyan"))
|
|
835
|
+
if background is True or str(background).lower() == "true":
|
|
836
|
+
return self._background(command)
|
|
818
837
|
marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
|
|
819
838
|
script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
|
|
820
839
|
token = ""
|
|
@@ -855,6 +874,8 @@ class Toolbox:
|
|
|
855
874
|
out: list[str] = []
|
|
856
875
|
shown, status, recorded = 0, "", ""
|
|
857
876
|
deadline = time.time() + int(timeout or 180)
|
|
877
|
+
finished = 0.0 # when the command itself ended (its marker arrived)
|
|
878
|
+
held_open = False # something it started still holds the output open
|
|
858
879
|
try:
|
|
859
880
|
while True:
|
|
860
881
|
if self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
@@ -862,6 +883,9 @@ class Toolbox:
|
|
|
862
883
|
try:
|
|
863
884
|
line = lines.get(timeout=0.2)
|
|
864
885
|
except queue.Empty:
|
|
886
|
+
if finished and time.time() - finished > LEFTOVER_GRACE:
|
|
887
|
+
held_open = True
|
|
888
|
+
break
|
|
865
889
|
if time.time() > deadline:
|
|
866
890
|
stop()
|
|
867
891
|
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
@@ -871,6 +895,7 @@ class Toolbox:
|
|
|
871
895
|
break
|
|
872
896
|
if line.startswith(marker):
|
|
873
897
|
recorded = line[len(marker) :].strip()
|
|
898
|
+
finished = time.time()
|
|
874
899
|
continue
|
|
875
900
|
out.append(line)
|
|
876
901
|
if shown <= 40:
|
|
@@ -881,6 +906,13 @@ class Toolbox:
|
|
|
881
906
|
stop()
|
|
882
907
|
raise
|
|
883
908
|
code = proc.wait()
|
|
909
|
+
if held_open and sandbox and token:
|
|
910
|
+
sandbox.kill(token) # what it left running in the container
|
|
911
|
+
if (not sandbox and stop_leftovers(proc.pid)) or held_open:
|
|
912
|
+
status += (
|
|
913
|
+
"\n[lcode stopped what this command left running (started with &). To keep a process running, "
|
|
914
|
+
"start it with background=true: then bash_output reads its output and bash_stop stops it]"
|
|
915
|
+
)
|
|
884
916
|
if out and out[-1] == "\n":
|
|
885
917
|
out.pop() # the blank line printed before the marker
|
|
886
918
|
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
@@ -933,6 +965,51 @@ class Toolbox:
|
|
|
933
965
|
raise ToolError(str(e)) from e
|
|
934
966
|
return web.format_page(url, title, text, max(1000, min(int(max_chars or 20000), MAX_TOOL_OUTPUT)))
|
|
935
967
|
|
|
968
|
+
def _background(self, command: str) -> str:
|
|
969
|
+
sandbox, kill = self.agent.sandbox, None
|
|
970
|
+
if sandbox:
|
|
971
|
+
try:
|
|
972
|
+
with self.console.status("Starting the sandbox…"):
|
|
973
|
+
sandbox.ensure(self.agent.cwd)
|
|
974
|
+
except SandboxError as e:
|
|
975
|
+
return f"Error: the sandbox can't start, so the command didn't run: {e}"
|
|
976
|
+
argv, token = sandbox.exec_argv(self.agent.cwd, command)
|
|
977
|
+
kill = lambda: sandbox.kill(token) # noqa: E731
|
|
978
|
+
else:
|
|
979
|
+
argv = ["bash", "-c", command]
|
|
980
|
+
job = self.agent.jobs.start(argv, self.agent.cwd, command, kill)
|
|
981
|
+
first = job.new_output(MAX_TOOL_OUTPUT // 3).rstrip()
|
|
982
|
+
for line in first.splitlines()[:10]:
|
|
983
|
+
self.console.print(Text(" " + line[:300], style="dim"))
|
|
984
|
+
if not job.running:
|
|
985
|
+
return (
|
|
986
|
+
f"The command ended right away ({job.status()}), so it isn't running in the background:\n"
|
|
987
|
+
f"{first or '(no output)'}"
|
|
988
|
+
)
|
|
989
|
+
self.console.print(Text(f" ⎿ background job {job.id}, /jobs to see it", style="dim"))
|
|
990
|
+
return (
|
|
991
|
+
f"Started background job {job.id}: it keeps running while you work. Read its new output with "
|
|
992
|
+
f'bash_output(id="{job.id}") and stop it with bash_stop(id="{job.id}") when it\'s no longer needed.\n'
|
|
993
|
+
f"Output so far:\n{first or '(none yet)'}"
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def t_bash_output(self, id: str) -> str:
|
|
997
|
+
try:
|
|
998
|
+
job = self.agent.jobs.get(id)
|
|
999
|
+
except JobError as e:
|
|
1000
|
+
raise ToolError(str(e)) from e
|
|
1001
|
+
text = job.new_output(MAX_TOOL_OUTPUT).rstrip()
|
|
1002
|
+
return f"[{describe(job)}]\n{text or '(no new output)'}"
|
|
1003
|
+
|
|
1004
|
+
def t_bash_stop(self, id: str) -> str:
|
|
1005
|
+
try:
|
|
1006
|
+
job = self.agent.jobs.stop(id)
|
|
1007
|
+
except JobError as e:
|
|
1008
|
+
raise ToolError(str(e)) from e
|
|
1009
|
+
self.console.print(Text(f" ⎿ {describe(job)}", style="dim"))
|
|
1010
|
+
last = job.new_output(4000).rstrip()
|
|
1011
|
+
return f"[{describe(job)}]" + (f"\nIts last output:\n{last}" if last else "")
|
|
1012
|
+
|
|
936
1013
|
# -- planning
|
|
937
1014
|
def t_todo_write(self, todos: list | str) -> str:
|
|
938
1015
|
if isinstance(todos, str):
|
lcode/update.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""`lcode update`: update lcode, and Ollama when it runs in Docker.
|
|
2
|
+
|
|
3
|
+
lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or `git pull` for a
|
|
4
|
+
checkout (an editable install).
|
|
5
|
+
|
|
6
|
+
For Ollama in Docker, the newest version is looked up on Docker Hub, never taken from a local
|
|
7
|
+
`latest` tag (which can be months old), and pulled by its version tag (`ollama/ollama:0.35.1`), so
|
|
8
|
+
`docker ps` shows which version runs. Release candidates are skipped, and a ROCm image stays ROCm.
|
|
9
|
+
- A container from Docker Compose gets the new tag in its compose file and is recreated with
|
|
10
|
+
`docker compose up`, so Compose keeps managing it.
|
|
11
|
+
- Any other container is recreated with the same name, ports, volumes, environment, GPUs, network
|
|
12
|
+
and restart policy. The old one is kept until the new one answers with the new version, and is
|
|
13
|
+
put back if it doesn't.
|
|
14
|
+
Ollama installed without Docker gets the command to update it, which lcode doesn't run itself.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.metadata
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
from lcode import __version__
|
|
34
|
+
|
|
35
|
+
IMAGE = "ollama/ollama"
|
|
36
|
+
HUB_TAGS = "https://hub.docker.com/v2/namespaces/ollama/repositories/ollama/tags"
|
|
37
|
+
PYPI = "https://pypi.org/pypi/lcode-cli/json"
|
|
38
|
+
SEMVER = re.compile(r"\d+\.\d+\.\d+")
|
|
39
|
+
START_TIMEOUT = 120 # seconds for a recreated container to answer
|
|
40
|
+
NATIVE = {
|
|
41
|
+
"linux": "curl -fsSL https://ollama.com/install.sh | sh",
|
|
42
|
+
"darwin": "brew upgrade ollama (or let the Ollama app update itself)",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UpdateError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def version_key(version: str) -> tuple[int, ...]:
|
|
51
|
+
return tuple(int(p) for p in re.findall(r"\d+", version)[:3])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(argv: list[str], cwd: Path | None = None, timeout: float = 120) -> subprocess.CompletedProcess:
|
|
55
|
+
"""A command whose output lcode reads (and tests replace)."""
|
|
56
|
+
try:
|
|
57
|
+
return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
58
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
59
|
+
raise UpdateError(f"{argv[0]} failed: {e}") from e
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stream(argv: list[str], cwd: Path | None = None) -> int:
|
|
63
|
+
"""A command the user watches (pulls, installs); returns its exit code."""
|
|
64
|
+
try:
|
|
65
|
+
return subprocess.call(argv, cwd=cwd)
|
|
66
|
+
except OSError as e:
|
|
67
|
+
raise UpdateError(f"{argv[0]} failed: {e}") from e
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_json(url: str, **params) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
r = requests.get(url, params=params or None, timeout=20)
|
|
73
|
+
r.raise_for_status()
|
|
74
|
+
return r.json()
|
|
75
|
+
except (requests.RequestException, ValueError) as e:
|
|
76
|
+
raise UpdateError(f"couldn't read {url.split('?')[0]}: {e}") from e
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ----------------------------------------------------------------------------- the newest versions
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def latest_lcode() -> str:
|
|
83
|
+
return str(get_json(PYPI)["info"]["version"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def latest_ollama(flavor: str = "") -> str:
|
|
87
|
+
"""The newest released Ollama image tag on Docker Hub: "0.35.1", or "0.35.1-rocm" for flavor "rocm"."""
|
|
88
|
+
names: list[str] = []
|
|
89
|
+
url, params = HUB_TAGS, {"page_size": 100, "ordering": "last_updated"}
|
|
90
|
+
for _ in range(3): # the newest tags come first; three pages reach well past them
|
|
91
|
+
page = get_json(url, **params)
|
|
92
|
+
names += [str(t.get("name", "")) for t in page.get("results") or []]
|
|
93
|
+
url, params = page.get("next"), {}
|
|
94
|
+
if not url:
|
|
95
|
+
break
|
|
96
|
+
suffix = f"-{flavor}" if flavor else ""
|
|
97
|
+
versions = [
|
|
98
|
+
n.removesuffix(suffix) for n in names if n.endswith(suffix) and SEMVER.fullmatch(n.removesuffix(suffix))
|
|
99
|
+
]
|
|
100
|
+
if not versions:
|
|
101
|
+
raise UpdateError(f"Docker Hub lists no released {IMAGE} versions{f' for {flavor}' if flavor else ''}")
|
|
102
|
+
return max(versions, key=version_key) + suffix
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def image_flavor(image: str) -> str:
|
|
106
|
+
"""The image variant to stay on: "rocm" for ollama/ollama:rocm or :0.35.1-rocm, else ""."""
|
|
107
|
+
tag = image.rsplit(":", 1)[1] if ":" in image.rsplit("/", 1)[-1] else "latest"
|
|
108
|
+
return "rocm" if tag == "rocm" or tag.endswith("-rocm") else ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ----------------------------------------------------------------------------- lcode itself
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Install:
|
|
116
|
+
kind: str # checkout, uv, pipx, brew, uvx, pip
|
|
117
|
+
command: list[str] | None = None # what updates it
|
|
118
|
+
path: Path | None = None # the checkout, for kind checkout
|
|
119
|
+
|
|
120
|
+
def describe(self) -> str:
|
|
121
|
+
return {
|
|
122
|
+
"checkout": f"a git checkout at {self.path}",
|
|
123
|
+
"uv": "uv tool",
|
|
124
|
+
"pipx": "pipx",
|
|
125
|
+
"brew": "Homebrew",
|
|
126
|
+
"uvx": "uvx",
|
|
127
|
+
"pip": "pip",
|
|
128
|
+
}[self.kind]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def lcode_install(prefix: Path | None = None, direct_url: str | None = None) -> Install:
|
|
132
|
+
"""How this lcode was installed."""
|
|
133
|
+
prefix = (prefix or Path(sys.prefix)).resolve()
|
|
134
|
+
if direct_url is None:
|
|
135
|
+
try:
|
|
136
|
+
direct_url = importlib.metadata.distribution("lcode-cli").read_text("direct_url.json") or ""
|
|
137
|
+
except importlib.metadata.PackageNotFoundError:
|
|
138
|
+
direct_url = ""
|
|
139
|
+
try:
|
|
140
|
+
origin = json.loads(direct_url) if direct_url else {}
|
|
141
|
+
except ValueError:
|
|
142
|
+
origin = {}
|
|
143
|
+
if (origin.get("dir_info") or {}).get("editable") and str(origin.get("url", "")).startswith("file://"):
|
|
144
|
+
return Install("checkout", path=Path(urlsplit(origin["url"]).path))
|
|
145
|
+
text = str(prefix)
|
|
146
|
+
if (prefix / "uv-receipt.toml").exists():
|
|
147
|
+
return Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])
|
|
148
|
+
if "/Cellar/lcode/" in text:
|
|
149
|
+
return Install("brew", ["brew", "upgrade", "nasser1941/tap/lcode"])
|
|
150
|
+
if "pipx" in prefix.parts:
|
|
151
|
+
return Install("pipx", ["pipx", "upgrade", "lcode-cli"])
|
|
152
|
+
if "/uv/" in text and ("archive-v" in text or "/cache/" in text or "/.cache/" in text):
|
|
153
|
+
return Install("uvx")
|
|
154
|
+
return Install("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "lcode-cli"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class CheckoutState:
|
|
159
|
+
branch: str
|
|
160
|
+
dirty: bool
|
|
161
|
+
behind: int
|
|
162
|
+
upstream: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def checkout_state(path: Path) -> CheckoutState:
|
|
166
|
+
def git(*args: str) -> str:
|
|
167
|
+
r = run(["git", "-C", str(path), *args], timeout=120)
|
|
168
|
+
if r.returncode != 0:
|
|
169
|
+
raise UpdateError(f"git {args[0]} failed in {path}: {(r.stderr or r.stdout).strip()[:200]}")
|
|
170
|
+
return r.stdout.strip()
|
|
171
|
+
|
|
172
|
+
branch = git("rev-parse", "--abbrev-ref", "HEAD")
|
|
173
|
+
dirty = bool(git("status", "--porcelain", "--untracked-files=no"))
|
|
174
|
+
upstream = run(["git", "-C", str(path), "rev-parse", "--abbrev-ref", "@{upstream}"]).stdout.strip()
|
|
175
|
+
behind = 0
|
|
176
|
+
if upstream:
|
|
177
|
+
git("fetch", "-q")
|
|
178
|
+
behind = int(git("rev-list", "--count", "HEAD..@{upstream}") or 0)
|
|
179
|
+
return CheckoutState(branch, dirty, behind, upstream)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def update_checkout(path: Path, install_receipt: bool) -> str:
|
|
183
|
+
"""git pull a checkout lcode runs from; reinstall its environment when its dependencies changed."""
|
|
184
|
+
before = run(["git", "-C", str(path), "rev-parse", "HEAD"]).stdout.strip()
|
|
185
|
+
if stream(["git", "-C", str(path), "pull", "--ff-only"]) != 0:
|
|
186
|
+
raise UpdateError(f"git pull failed in {path}")
|
|
187
|
+
changed = run(["git", "-C", str(path), "diff", "--name-only", before, "HEAD"]).stdout.split()
|
|
188
|
+
reinstall = ["uv", "tool", "install", "--force", "--editable", str(path)]
|
|
189
|
+
if "pyproject.toml" in changed and install_receipt and stream(reinstall) != 0:
|
|
190
|
+
raise UpdateError("reinstalling lcode's environment failed: run uv tool install --force --editable .")
|
|
191
|
+
count = run(["git", "-C", str(path), "rev-list", "--count", f"{before}..HEAD"]).stdout.strip() or "0"
|
|
192
|
+
return f"{count} new commit(s)"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ----------------------------------------------------------------------------- Ollama in Docker
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class Container:
|
|
200
|
+
id: str
|
|
201
|
+
name: str
|
|
202
|
+
image: str # as configured, e.g. ollama/ollama:latest
|
|
203
|
+
info: dict = field(repr=False)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def compose(self) -> dict | None:
|
|
207
|
+
labels = (self.info.get("Config") or {}).get("Labels") or {}
|
|
208
|
+
project, service = labels.get("com.docker.compose.project"), labels.get("com.docker.compose.service")
|
|
209
|
+
if not project or not service:
|
|
210
|
+
return None
|
|
211
|
+
files = [f for f in str(labels.get("com.docker.compose.project.config_files", "")).split(",") if f]
|
|
212
|
+
return {"project": project, "service": service, "files": files,
|
|
213
|
+
"workdir": labels.get("com.docker.compose.project.working_dir", "")} # fmt: skip
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def docker_available() -> bool:
|
|
217
|
+
return shutil.which("docker") is not None and run(["docker", "info", "--format", "{{.ID}}"]).returncode == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def find_container(host: str) -> Container | None:
|
|
221
|
+
"""The Ollama container that serves `host` (a local address), if Ollama runs in Docker."""
|
|
222
|
+
parts = urlsplit(host)
|
|
223
|
+
if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None):
|
|
224
|
+
return None
|
|
225
|
+
port = str(parts.port or 11434)
|
|
226
|
+
ids = run(["docker", "ps", "-q"]).stdout.split()
|
|
227
|
+
if not ids:
|
|
228
|
+
return None
|
|
229
|
+
infos = json.loads(run(["docker", "inspect", *ids]).stdout or "[]")
|
|
230
|
+
found = []
|
|
231
|
+
for info in infos:
|
|
232
|
+
image = (info.get("Config") or {}).get("Image", "")
|
|
233
|
+
if not image.startswith(IMAGE):
|
|
234
|
+
continue
|
|
235
|
+
bindings = ((info.get("HostConfig") or {}).get("PortBindings") or {}).get("11434/tcp") or []
|
|
236
|
+
if any(str(b.get("HostPort")) == port for b in bindings):
|
|
237
|
+
found.append(Container(info["Id"][:12], info["Name"].lstrip("/"), image, info))
|
|
238
|
+
return found[0] if len(found) == 1 else None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def set_compose_image(text: str, service: str, image: str) -> tuple[str, str]:
|
|
242
|
+
"""The compose file with the service's image changed, and the image it had."""
|
|
243
|
+
lines = text.splitlines(keepends=True)
|
|
244
|
+
services = next((i for i, line in enumerate(lines) if re.match(r"services:\s*(#.*)?$", line)), None)
|
|
245
|
+
if services is None:
|
|
246
|
+
raise UpdateError("no services: section")
|
|
247
|
+
start = indent = None
|
|
248
|
+
for i in range(services + 1, len(lines)):
|
|
249
|
+
m = re.match(rf"(\s+)['\"]?{re.escape(service)}['\"]?:\s*(#.*)?$", lines[i])
|
|
250
|
+
if m:
|
|
251
|
+
start, indent = i, len(m.group(1))
|
|
252
|
+
break
|
|
253
|
+
if lines[i].strip() and not lines[i][0].isspace() and not lines[i].lstrip().startswith("#"):
|
|
254
|
+
break # the next top-level key: services ended
|
|
255
|
+
if start is None or indent is None:
|
|
256
|
+
raise UpdateError(f"no service {service} in it")
|
|
257
|
+
for i in range(start + 1, len(lines)):
|
|
258
|
+
line = lines[i]
|
|
259
|
+
if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent:
|
|
260
|
+
break # the next service
|
|
261
|
+
m = re.match(r"(\s+image:\s*)(['\"]?)([^'\"#\s]+)\2(\s*(#.*)?)$", line.rstrip("\n"))
|
|
262
|
+
if m:
|
|
263
|
+
if "${" in line:
|
|
264
|
+
raise UpdateError(f"the image comes from a variable ({m.group(3)}): set it to {image}")
|
|
265
|
+
newline = "\n" if line.endswith("\n") else ""
|
|
266
|
+
lines[i] = f"{m.group(1)}{m.group(2)}{image}{m.group(2)}{m.group(4)}{newline}"
|
|
267
|
+
return "".join(lines), m.group(3)
|
|
268
|
+
raise UpdateError(f"service {service} has no image: line (it may be built from a Dockerfile)")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run_args(info: dict, image_info: dict, image: str) -> list[str]:
|
|
272
|
+
"""`docker run` arguments that recreate a container with another image and the same settings."""
|
|
273
|
+
config, host = info.get("Config") or {}, info.get("HostConfig") or {}
|
|
274
|
+
base = image_info.get("Config") or {}
|
|
275
|
+
args = ["docker", "run", "-d", "--name", info["Name"].lstrip("/")]
|
|
276
|
+
restart = host.get("RestartPolicy") or {}
|
|
277
|
+
if restart.get("Name") not in (None, "", "no"):
|
|
278
|
+
retries = restart.get("MaximumRetryCount") or 0
|
|
279
|
+
args += ["--restart", restart["Name"] + (f":{retries}" if restart["Name"] == "on-failure" and retries else "")]
|
|
280
|
+
for port, bindings in (host.get("PortBindings") or {}).items():
|
|
281
|
+
target = port.removesuffix("/tcp")
|
|
282
|
+
for b in bindings or []:
|
|
283
|
+
ip = b.get("HostIp") or ""
|
|
284
|
+
args += ["-p", f"{ip + ':' if ip else ''}{b.get('HostPort', '')}:{target}"]
|
|
285
|
+
for m in info.get("Mounts") or []:
|
|
286
|
+
source = m.get("Name") if m.get("Type") == "volume" else m.get("Source")
|
|
287
|
+
if source and m.get("Type") in ("volume", "bind"):
|
|
288
|
+
args += ["-v", f"{source}:{m['Destination']}" + ("" if m.get("RW", True) else ":ro")]
|
|
289
|
+
default_env = set(base.get("Env") or [])
|
|
290
|
+
for entry in config.get("Env") or []:
|
|
291
|
+
if entry not in default_env: # only what was set for this container; the new image brings its own PATH
|
|
292
|
+
args += ["-e", entry]
|
|
293
|
+
for request in host.get("DeviceRequests") or []:
|
|
294
|
+
if ["gpu"] in (request.get("Capabilities") or []):
|
|
295
|
+
ids = request.get("DeviceIDs") or []
|
|
296
|
+
count = request.get("Count") or 0
|
|
297
|
+
args += ["--gpus", f"device={','.join(ids)}" if ids else ("all" if count == -1 else str(count))]
|
|
298
|
+
for device in host.get("Devices") or []:
|
|
299
|
+
args += ["--device", f"{device['PathOnHost']}:{device['PathInContainer']}"]
|
|
300
|
+
if host.get("Runtime") not in (None, "", "runc"):
|
|
301
|
+
args += ["--runtime", host["Runtime"]]
|
|
302
|
+
network = host.get("NetworkMode") or "default"
|
|
303
|
+
if network not in ("default", "bridge") and not network.startswith("container:"):
|
|
304
|
+
args += ["--network", network]
|
|
305
|
+
endpoint = ((info.get("NetworkSettings") or {}).get("Networks") or {}).get(network) or {}
|
|
306
|
+
name, short_id = info["Name"].lstrip("/"), info["Id"][:12]
|
|
307
|
+
for alias in endpoint.get("Aliases") or []:
|
|
308
|
+
if alias not in (name, short_id):
|
|
309
|
+
args += ["--network-alias", alias]
|
|
310
|
+
for host_entry in host.get("ExtraHosts") or []:
|
|
311
|
+
args += ["--add-host", host_entry]
|
|
312
|
+
base_labels = base.get("Labels") or {}
|
|
313
|
+
for key, value in (config.get("Labels") or {}).items():
|
|
314
|
+
if base_labels.get(key) != value:
|
|
315
|
+
args += ["--label", f"{key}={value}"]
|
|
316
|
+
entrypoint, cmd = config.get("Entrypoint") or [], config.get("Cmd") or []
|
|
317
|
+
if entrypoint and entrypoint != (base.get("Entrypoint") or []):
|
|
318
|
+
args += ["--entrypoint", entrypoint[0]]
|
|
319
|
+
cmd = [*entrypoint[1:], *cmd]
|
|
320
|
+
elif cmd == (base.get("Cmd") or []):
|
|
321
|
+
cmd = []
|
|
322
|
+
return [*args, image, *cmd]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def wait_for_version(host: str, wanted: str, timeout: float = START_TIMEOUT) -> bool:
|
|
326
|
+
from lcode.ollama import Ollama, OllamaError
|
|
327
|
+
|
|
328
|
+
deadline = time.monotonic() + timeout
|
|
329
|
+
while time.monotonic() < deadline:
|
|
330
|
+
try:
|
|
331
|
+
if Ollama(host).version() == wanted.removesuffix("-rocm"):
|
|
332
|
+
return True
|
|
333
|
+
except OllamaError:
|
|
334
|
+
pass
|
|
335
|
+
time.sleep(2)
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def recreate(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
|
|
340
|
+
"""Replace a plain container with one on `image`; put the old one back if that fails."""
|
|
341
|
+
image_info = json.loads(run(["docker", "image", "inspect", container.info["Image"]]).stdout or "[{}]")[0]
|
|
342
|
+
args = run_args(container.info, image_info, image)
|
|
343
|
+
backup = f"{container.name}-before-lcode-update"
|
|
344
|
+
for step in (["docker", "stop", container.name], ["docker", "rename", container.name, backup]):
|
|
345
|
+
r = run(step)
|
|
346
|
+
if r.returncode != 0:
|
|
347
|
+
raise UpdateError(f"{' '.join(step[:2])} failed: {(r.stderr or r.stdout).strip()}")
|
|
348
|
+
say(f"$ {' '.join(args)}")
|
|
349
|
+
started = run(args)
|
|
350
|
+
if started.returncode == 0 and wait_for_version(host, image.rsplit(":", 1)[1]):
|
|
351
|
+
run(["docker", "rm", backup])
|
|
352
|
+
return
|
|
353
|
+
problem = (started.stderr or started.stdout).strip() or "it didn't answer with the new version"
|
|
354
|
+
run(["docker", "rm", "-f", container.name])
|
|
355
|
+
run(["docker", "rename", backup, container.name])
|
|
356
|
+
run(["docker", "start", container.name])
|
|
357
|
+
raise UpdateError(f"the new container didn't start ({problem[:300]}); the old one is running again")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def recreate_with_compose(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
|
|
361
|
+
command, project_dir = compose_up(container.compose or {})
|
|
362
|
+
say(f"$ {' '.join(command)}")
|
|
363
|
+
if stream(command, cwd=project_dir) != 0:
|
|
364
|
+
raise UpdateError("docker compose up failed")
|
|
365
|
+
if not wait_for_version(host, image.rsplit(":", 1)[1]):
|
|
366
|
+
raise UpdateError(f"Ollama didn't answer with version {image.rsplit(':', 1)[1]} after the restart")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def compose_up(compose: dict) -> tuple[list[str], Path]:
|
|
370
|
+
"""`docker compose up` for just the Ollama service of its project, and where to run it."""
|
|
371
|
+
files = [Path(f) for f in compose["files"]]
|
|
372
|
+
project_dir = Path(compose["workdir"]) if compose.get("workdir") else files[0].parent
|
|
373
|
+
command = ["docker", "compose", "-p", compose["project"], "--project-directory", str(project_dir)]
|
|
374
|
+
for f in files:
|
|
375
|
+
command += ["-f", str(f)]
|
|
376
|
+
return [*command, "up", "-d", "--no-deps", compose["service"]], project_dir
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def compose_change(container: Container, image: str) -> tuple[Path, str, str]:
|
|
380
|
+
"""Which compose file defines the service's image, and its new text."""
|
|
381
|
+
compose = container.compose or {}
|
|
382
|
+
problems = []
|
|
383
|
+
for path in reversed([Path(f) for f in compose.get("files") or []]): # later files override earlier ones
|
|
384
|
+
try:
|
|
385
|
+
text, old = set_compose_image(path.read_text(), compose["service"], image)
|
|
386
|
+
return path, text, old
|
|
387
|
+
except (OSError, UpdateError) as e:
|
|
388
|
+
problems.append(f"{path}: {e}")
|
|
389
|
+
raise UpdateError("couldn't find the image to change: " + "; ".join(problems))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ----------------------------------------------------------------------------- lcode update
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def command(console, what: str = "", check: bool = False, yes: bool = False) -> int:
|
|
396
|
+
"""Update lcode and Ollama (or `what`: "lcode" or "ollama"). Returns the exit code."""
|
|
397
|
+
from rich.prompt import Confirm
|
|
398
|
+
|
|
399
|
+
def ask(question: str) -> bool:
|
|
400
|
+
if yes:
|
|
401
|
+
return True
|
|
402
|
+
if not sys.stdin.isatty():
|
|
403
|
+
console.print("[dim]Not asking without a terminal: run lcode update -y to go ahead.[/]")
|
|
404
|
+
return False
|
|
405
|
+
return Confirm.ask(question, default=True, console=console)
|
|
406
|
+
|
|
407
|
+
failed = False
|
|
408
|
+
for part, step in (("lcode", update_lcode), ("ollama", update_ollama)):
|
|
409
|
+
if what and what != part:
|
|
410
|
+
continue
|
|
411
|
+
try:
|
|
412
|
+
step(console, check, ask)
|
|
413
|
+
except UpdateError as e:
|
|
414
|
+
console.print(f"[red]✗ {escape(str(e))}[/]")
|
|
415
|
+
failed = True
|
|
416
|
+
console.print()
|
|
417
|
+
return 1 if failed else 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def escape(text: str) -> str:
|
|
421
|
+
from rich.markup import escape as rich_escape
|
|
422
|
+
|
|
423
|
+
return rich_escape(text)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def update_lcode(console, check: bool, ask: Callable[[str], bool]) -> None:
|
|
427
|
+
install = lcode_install()
|
|
428
|
+
if install.kind == "checkout" and install.path is not None:
|
|
429
|
+
state = checkout_state(install.path)
|
|
430
|
+
where = f"lcode {__version__} · {install.describe()}, branch {state.branch}"
|
|
431
|
+
if not state.upstream:
|
|
432
|
+
console.print(f"{where}: no upstream branch to update from.")
|
|
433
|
+
elif not state.behind:
|
|
434
|
+
console.print(f"[green]✓[/] {where}: up to date with {state.upstream}.")
|
|
435
|
+
elif check:
|
|
436
|
+
console.print(f"{where}: {state.behind} new commit(s) on {state.upstream}.")
|
|
437
|
+
elif state.dirty or state.branch not in ("main", "master"):
|
|
438
|
+
why = "has uncommitted changes" if state.dirty else f"is on branch {state.branch}"
|
|
439
|
+
console.print(
|
|
440
|
+
f"{where}: {state.behind} new commit(s) on {state.upstream}, but the checkout {why}. Update it yourself with git pull."
|
|
441
|
+
)
|
|
442
|
+
elif ask(f"Pull {state.behind} new commit(s) into {install.path}?"):
|
|
443
|
+
receipt = (Path(sys.prefix) / "uv-receipt.toml").exists()
|
|
444
|
+
console.print(f"[green]✓[/] lcode updated: {update_checkout(install.path, receipt)}.")
|
|
445
|
+
return
|
|
446
|
+
latest = latest_lcode()
|
|
447
|
+
if version_key(latest) <= version_key(__version__):
|
|
448
|
+
console.print(f"[green]✓[/] lcode {__version__} is the newest version ({install.describe()}).")
|
|
449
|
+
return
|
|
450
|
+
if install.command is None:
|
|
451
|
+
console.print(
|
|
452
|
+
f"lcode {__version__} → {latest} is out. You run lcode with uvx: use uvx --refresh --from lcode-cli lcode"
|
|
453
|
+
)
|
|
454
|
+
return
|
|
455
|
+
shown = " ".join(install.command)
|
|
456
|
+
if check:
|
|
457
|
+
console.print(f"lcode {__version__} → {latest} is out ({install.describe()}): {shown}")
|
|
458
|
+
return
|
|
459
|
+
if not ask(f"Update lcode {__version__} → {latest} with `{shown}`?"):
|
|
460
|
+
return
|
|
461
|
+
if stream(install.command) != 0:
|
|
462
|
+
raise UpdateError(f"{shown} failed")
|
|
463
|
+
console.print("[green]✓[/] lcode updated; the new version runs from the next `lcode`.")
|
|
464
|
+
if install.kind == "brew":
|
|
465
|
+
console.print(
|
|
466
|
+
"[dim]Homebrew's formula follows a release by up to a day: if it's still the old version, try again later.[/]"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def update_ollama(console, check: bool, ask: Callable[[str], bool]) -> None:
|
|
471
|
+
from lcode import config
|
|
472
|
+
from lcode.hardware import detect
|
|
473
|
+
from lcode.ollama import Ollama, OllamaError
|
|
474
|
+
|
|
475
|
+
cfg = config.load()
|
|
476
|
+
if cfg["backend"] != "ollama":
|
|
477
|
+
console.print(f"lcode uses another model server (backend = {cfg['backend']}): update it there.")
|
|
478
|
+
return
|
|
479
|
+
host = cfg["ollama_host"]
|
|
480
|
+
try:
|
|
481
|
+
current = Ollama(host).version()
|
|
482
|
+
except OllamaError:
|
|
483
|
+
current = ""
|
|
484
|
+
container = find_container(host) if docker_available() else None
|
|
485
|
+
if container is None:
|
|
486
|
+
if not current:
|
|
487
|
+
raise UpdateError(f"Ollama doesn't answer at {host}: start it, then run lcode update again")
|
|
488
|
+
latest = latest_ollama()
|
|
489
|
+
local = urlsplit(host).hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None)
|
|
490
|
+
if version_key(latest) <= version_key(current):
|
|
491
|
+
console.print(f"[green]✓[/] Ollama {current} is the newest version.")
|
|
492
|
+
elif not local:
|
|
493
|
+
console.print(f"Ollama {current} → {latest} is out. It runs on {host}: update it on that machine.")
|
|
494
|
+
else:
|
|
495
|
+
hint = NATIVE.get(detect().os, NATIVE["linux"])
|
|
496
|
+
console.print(
|
|
497
|
+
f"Ollama {current} → {latest} is out. Ollama isn't in a Docker container here, so update it with:\n {hint}"
|
|
498
|
+
)
|
|
499
|
+
return
|
|
500
|
+
latest = latest_ollama(image_flavor(container.image))
|
|
501
|
+
image = f"{IMAGE}:{latest}"
|
|
502
|
+
compose = container.compose
|
|
503
|
+
where = f"container {container.name}, image {container.image}" + (
|
|
504
|
+
f", Docker Compose project {compose['project']}" if compose else ""
|
|
505
|
+
)
|
|
506
|
+
console.print(f"Ollama {current or '?'} · {where}. Newest on Docker Hub: {latest}.")
|
|
507
|
+
if container.image == image and current == latest.removesuffix("-rocm"):
|
|
508
|
+
console.print(f"[green]✓[/] Ollama is up to date, on {image}.")
|
|
509
|
+
return
|
|
510
|
+
if current == latest.removesuffix("-rocm"):
|
|
511
|
+
console.print(
|
|
512
|
+
f"[dim]It already runs {current}, but as {container.image}: pinning it to {image} shows the version in docker ps.[/]"
|
|
513
|
+
)
|
|
514
|
+
change = compose_change(container, image) if compose else None
|
|
515
|
+
plan = [f"docker pull {image}"]
|
|
516
|
+
if change:
|
|
517
|
+
path, _, old = change
|
|
518
|
+
plan.append(f"in {path}, service {compose['service']}: image {old} → {image}") # type: ignore[index]
|
|
519
|
+
plan.append(f"docker compose up -d --no-deps {compose['service']}") # type: ignore[index]
|
|
520
|
+
else:
|
|
521
|
+
plan.append(
|
|
522
|
+
f"recreate container {container.name} on {image}, with the same name, ports, volumes, environment, GPUs, network and restart policy (the old one is kept until the new one answers)"
|
|
523
|
+
)
|
|
524
|
+
console.print("\n".join(f" {i}. {escape(step)}" for i, step in enumerate(plan, 1)))
|
|
525
|
+
if check:
|
|
526
|
+
return
|
|
527
|
+
console.print("[dim]Ollama restarts: requests running now stop, and models load again on their next request.[/]")
|
|
528
|
+
if not ask("Update Ollama?"):
|
|
529
|
+
return
|
|
530
|
+
if stream(["docker", "pull", image]) != 0:
|
|
531
|
+
raise UpdateError(f"docker pull {image} failed")
|
|
532
|
+
|
|
533
|
+
def say(text: str) -> None:
|
|
534
|
+
console.print(f"[dim]{escape(text)}[/]")
|
|
535
|
+
|
|
536
|
+
if change:
|
|
537
|
+
path, text, _ = change
|
|
538
|
+
original = path.read_text()
|
|
539
|
+
path.write_text(text)
|
|
540
|
+
try:
|
|
541
|
+
recreate_with_compose(container, image, host, say)
|
|
542
|
+
except UpdateError:
|
|
543
|
+
path.write_text(original)
|
|
544
|
+
say(f"put {path} back the way it was")
|
|
545
|
+
command, workdir = compose_up(compose) # type: ignore[arg-type]
|
|
546
|
+
stream(command, cwd=workdir)
|
|
547
|
+
raise
|
|
548
|
+
else:
|
|
549
|
+
recreate(container, image, host, say)
|
|
550
|
+
console.print(f"[green]✓[/] Ollama {current or '?'} → {latest.removesuffix('-rocm')}, running as {image}.")
|
|
551
|
+
old_image = container.info.get("Image", "")
|
|
552
|
+
if old_image and not run(["docker", "ps", "-aq", "--filter", f"ancestor={old_image}"]).stdout.strip():
|
|
553
|
+
size = run(["docker", "image", "inspect", old_image, "--format", "{{.Size}}"]).stdout.strip()
|
|
554
|
+
gb = f" ({int(size) / 1e9:.1f} GB)" if size.isdigit() else ""
|
|
555
|
+
console.print(
|
|
556
|
+
f"[dim]The previous image is still on disk{gb}. To remove it: docker image rm {old_image[7:19]}[/]"
|
|
557
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.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/
|
|
@@ -216,7 +216,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
216
216
|
|
|
217
217
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
218
218
|
|
|
219
|
-
- [Long-running work: finish notifications and background commands](https://github.com/nasser1941/lcode/issues/51)
|
|
220
219
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
221
220
|
|
|
222
221
|
## Contributing
|
|
@@ -1,37 +1,40 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=CcNLwxNc-qPLNMDrdZMe5h6rRYH_9ISij5uyXwc5PBA,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/acp.py,sha256=
|
|
3
|
+
lcode/acp.py,sha256=kRD_uuDDdGXt7xwvXZ8OqePK1HprEV76wWAWkV_gcak,28987
|
|
4
4
|
lcode/action.py,sha256=1r3abjheYFYQ-EUArvotls8pLmE3YjNMGHAy_iF0Z70,16198
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=FgZJpGD1a46qaa1nixZYXS3rPaFaJgnGpfoUi7ikhGY,51106
|
|
6
|
+
lcode/api.py,sha256=rOpcA2-axzUj_DppH3EelanVnwd-2O4MgN91OXGJePY,15250
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
11
11
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
12
|
-
lcode/cli.py,sha256=
|
|
12
|
+
lcode/cli.py,sha256=ue5wsoFxraXXVu7X32S4tbKPLKYhgeI6zpK2i5nXXlU,40180
|
|
13
13
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
14
|
-
lcode/config.py,sha256=
|
|
14
|
+
lcode/config.py,sha256=eDEvOAA1plsSjvihzHEwGjNVgvcz3Pb-AR5DY5JmXnM,10255
|
|
15
15
|
lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
|
|
16
16
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
17
17
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
18
18
|
lcode/gitflow.py,sha256=y78dqqh80is-MiDTnSHnK9vjx0vFovor_SZ8hW1NJaU,27167
|
|
19
19
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
20
20
|
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
21
|
+
lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
21
22
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
22
23
|
lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
23
24
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
24
25
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
26
|
+
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
25
27
|
lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
26
28
|
lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
|
|
27
|
-
lcode/planning.py,sha256=
|
|
29
|
+
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
28
30
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
29
|
-
lcode/repl.py,sha256=
|
|
31
|
+
lcode/repl.py,sha256=pQAPePNsBxJfcuif2Cvyxr0FSeO_DVGv_T5cL9BrWu4,48470
|
|
30
32
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
31
33
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
32
34
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
33
|
-
lcode/subagents.py,sha256=
|
|
34
|
-
lcode/tools.py,sha256=
|
|
35
|
+
lcode/subagents.py,sha256=rD7lnIioodirh8eVnXvRWdZx79J_rscPLssD2Gt7KIo,28914
|
|
36
|
+
lcode/tools.py,sha256=pOdA4ihQHSs8yWUzfxhiLw3GajF5Dr1edxVX0Akr6CU,47257
|
|
37
|
+
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
35
38
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
36
39
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
37
40
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -49,8 +52,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
|
49
52
|
lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
50
53
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
51
54
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
52
|
-
lcode_cli-0.
|
|
53
|
-
lcode_cli-0.
|
|
54
|
-
lcode_cli-0.
|
|
55
|
-
lcode_cli-0.
|
|
56
|
-
lcode_cli-0.
|
|
55
|
+
lcode_cli-0.21.0.dist-info/METADATA,sha256=5G93iKQq-92dbzpKJxamXKr9giXtXiVm0AsqhcJf4NU,12527
|
|
56
|
+
lcode_cli-0.21.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
57
|
+
lcode_cli-0.21.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
|
|
58
|
+
lcode_cli-0.21.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
59
|
+
lcode_cli-0.21.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|