lcode-cli 0.4.1__py3-none-any.whl → 0.6.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 +90 -2
- lcode/cli.py +36 -0
- lcode/config.py +11 -0
- lcode/mcp/catalog.toml +28 -0
- lcode/mcp/config.py +5 -1
- lcode/mcp/manager.py +8 -3
- lcode/mcp/protocol.py +5 -2
- lcode/ollama.py +4 -0
- lcode/repl.py +33 -0
- lcode/sandbox.py +204 -0
- lcode/tools.py +86 -28
- lcode/vision.py +109 -0
- {lcode_cli-0.4.1.dist-info → lcode_cli-0.6.0.dist-info}/METADATA +7 -3
- {lcode_cli-0.4.1.dist-info → lcode_cli-0.6.0.dist-info}/RECORD +18 -16
- {lcode_cli-0.4.1.dist-info → lcode_cli-0.6.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.4.1.dist-info → lcode_cli-0.6.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.4.1.dist-info → lcode_cli-0.6.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import datetime as dt
|
|
6
7
|
import json
|
|
7
8
|
import platform
|
|
@@ -19,18 +20,21 @@ from rich.markup import escape
|
|
|
19
20
|
from rich.panel import Panel
|
|
20
21
|
from rich.text import Text
|
|
21
22
|
|
|
22
|
-
from lcode import catalog, limits, sessions, web
|
|
23
|
+
from lcode import catalog, limits, sessions, vision, web
|
|
23
24
|
from lcode.checkpoints import Checkpoints
|
|
24
25
|
from lcode.config import format_tokens
|
|
25
26
|
from lcode.mcp import McpManager
|
|
26
27
|
from lcode.ollama import Ollama, OllamaError
|
|
27
28
|
from lcode.permissions import Permissions
|
|
28
29
|
from lcode.render import MarkdownStreamer
|
|
30
|
+
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
29
31
|
from lcode.tools import (
|
|
30
32
|
SCHEMAS,
|
|
33
|
+
VIEW_IMAGE_SCHEMA,
|
|
31
34
|
WEB_FETCH_SCHEMA,
|
|
32
35
|
WEB_SEARCH_SCHEMA,
|
|
33
36
|
Toolbox,
|
|
37
|
+
ToolError,
|
|
34
38
|
is_binary,
|
|
35
39
|
parse_text_tool_calls,
|
|
36
40
|
tree,
|
|
@@ -140,6 +144,10 @@ class Settings:
|
|
|
140
144
|
search_backend: str = "auto"
|
|
141
145
|
searxng_url: str | None = None
|
|
142
146
|
checkpoints: bool = True # snapshot files before the model changes them, for /undo
|
|
147
|
+
sandbox: str = "off" # off, docker or podman: where the model's shell commands run
|
|
148
|
+
sandbox_image: str | None = None
|
|
149
|
+
sandbox_network: bool = False
|
|
150
|
+
vision_model: str = "auto" # auto, off or an Ollama model that can see images
|
|
143
151
|
|
|
144
152
|
|
|
145
153
|
class Agent:
|
|
@@ -153,7 +161,13 @@ class Agent:
|
|
|
153
161
|
self.session_id = self.new_session_id()
|
|
154
162
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
155
163
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
164
|
+
self.sandbox = (
|
|
165
|
+
Sandbox(settings.sandbox, settings.sandbox_image, settings.sandbox_network, self.console)
|
|
166
|
+
if settings.sandbox != "off"
|
|
167
|
+
else None
|
|
168
|
+
)
|
|
156
169
|
self._mcp_prompt = "" # the MCP part at the end of the system prompt
|
|
170
|
+
self._vision: str | bool | None = False # the model that looks at images; False = not decided yet
|
|
157
171
|
self.session_name = ""
|
|
158
172
|
self.session_title = ""
|
|
159
173
|
self.ctx_used = 0
|
|
@@ -195,10 +209,65 @@ class Agent:
|
|
|
195
209
|
schemas = list(SCHEMAS)
|
|
196
210
|
if self.settings.web != "off":
|
|
197
211
|
schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
|
|
212
|
+
if self.vision_model():
|
|
213
|
+
schemas.append(VIEW_IMAGE_SCHEMA)
|
|
198
214
|
if self.mcp:
|
|
199
215
|
schemas += self.mcp.schemas(self.settings.context)
|
|
200
216
|
return schemas
|
|
201
217
|
|
|
218
|
+
def vision_model(self) -> str | None:
|
|
219
|
+
"""The model that looks at images for this session, if any (see lcode.vision)."""
|
|
220
|
+
if self._vision is False:
|
|
221
|
+
self._vision = vision.pick_model(self.ollama, self.settings.model, self.settings.vision_model)
|
|
222
|
+
return self._vision or None
|
|
223
|
+
|
|
224
|
+
def free_gpu(self) -> list[str]:
|
|
225
|
+
"""Unload lcode's models from Ollama so another program can use the GPU; the next request reloads.
|
|
226
|
+
|
|
227
|
+
Only lcode's own models: the session's and the one that looks at images.
|
|
228
|
+
"""
|
|
229
|
+
mine = {self.settings.model, *([self._vision] if isinstance(self._vision, str) else [])}
|
|
230
|
+
freed = []
|
|
231
|
+
for entry in self.ollama.running():
|
|
232
|
+
name = entry.get("name") or entry.get("model") or ""
|
|
233
|
+
if name in mine or name.removesuffix(":latest") in mine:
|
|
234
|
+
try:
|
|
235
|
+
self.ollama.unload(name)
|
|
236
|
+
freed.append(name.removesuffix(":latest"))
|
|
237
|
+
except OllamaError:
|
|
238
|
+
pass
|
|
239
|
+
return freed
|
|
240
|
+
|
|
241
|
+
def look(self, path: Path, question: str = "") -> str:
|
|
242
|
+
"""Describe an image with the vision model. Raises ToolError if it can't."""
|
|
243
|
+
model = self.vision_model()
|
|
244
|
+
if not model:
|
|
245
|
+
raise ToolError(f"can't look at {path.name}: {vision.INSTALL_HINT}")
|
|
246
|
+
try:
|
|
247
|
+
image = vision.read_image(path)
|
|
248
|
+
# The session's own model keeps its settings, so Ollama doesn't reload it.
|
|
249
|
+
options = self.options() if model == self.settings.model else None
|
|
250
|
+
loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
|
|
251
|
+
with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
|
|
252
|
+
return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
|
|
253
|
+
except vision.VisionError as e:
|
|
254
|
+
raise ToolError(str(e)) from e
|
|
255
|
+
|
|
256
|
+
def describe_image_data(self, data: str, mime: str) -> str:
|
|
257
|
+
"""For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
|
|
258
|
+
model = self.vision_model()
|
|
259
|
+
if not model:
|
|
260
|
+
return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
|
|
261
|
+
options = self.options() if model == self.settings.model else None
|
|
262
|
+
try:
|
|
263
|
+
with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
|
|
264
|
+
text = vision.describe(
|
|
265
|
+
self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
|
|
266
|
+
)
|
|
267
|
+
except (vision.VisionError, ValueError) as e:
|
|
268
|
+
return f"[{mime} image not shown: {e}]"
|
|
269
|
+
return f"[{mime} image, as described by {model}]\n{text}"
|
|
270
|
+
|
|
202
271
|
def prepare_mcp(self) -> None:
|
|
203
272
|
"""Before a request: wait for MCP servers still starting and describe them in the system prompt."""
|
|
204
273
|
if not self.mcp:
|
|
@@ -462,6 +531,15 @@ class Agent:
|
|
|
462
531
|
finally:
|
|
463
532
|
self.checkpoints.end_turn()
|
|
464
533
|
|
|
534
|
+
def sandbox_root(self) -> Path | None:
|
|
535
|
+
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
|
536
|
+
if not self.sandbox:
|
|
537
|
+
return None
|
|
538
|
+
try:
|
|
539
|
+
return project_root(self.cwd)
|
|
540
|
+
except SandboxError:
|
|
541
|
+
return self.cwd
|
|
542
|
+
|
|
465
543
|
def checkpoint(self) -> None:
|
|
466
544
|
"""Called before the model changes files: snapshot them once per request, for /undo."""
|
|
467
545
|
self.checkpoints.before_change(self.cwd)
|
|
@@ -541,7 +619,17 @@ class Agent:
|
|
|
541
619
|
attached = []
|
|
542
620
|
for ref in re.findall(r"(?<!\S)@([\w./~\-]+)", text):
|
|
543
621
|
p = self.tools.resolve(ref)
|
|
544
|
-
if p.is_file() and
|
|
622
|
+
if p.is_file() and vision.is_image(p):
|
|
623
|
+
try:
|
|
624
|
+
description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
|
|
625
|
+
who = f' described_by="{self.vision_model()}"'
|
|
626
|
+
except ToolError as e:
|
|
627
|
+
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
628
|
+
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
629
|
+
else:
|
|
630
|
+
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
631
|
+
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
632
|
+
elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
|
|
545
633
|
attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
|
|
546
634
|
self.tools.read_mtimes[str(p)] = p.stat().st_mtime
|
|
547
635
|
self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
|
lcode/cli.py
CHANGED
|
@@ -273,6 +273,17 @@ def cmd_doctor(args) -> None:
|
|
|
273
273
|
if spec:
|
|
274
274
|
detail += f" · ~{spec.memory_gib(ctx):.0f} GB needed, ~{hw.budget_gib:.0f} GB available"
|
|
275
275
|
line("Context", detail + (f" ({note})" if note else ""), None if note else True)
|
|
276
|
+
from lcode import vision
|
|
277
|
+
|
|
278
|
+
seer = vision.pick_model(ollama, model, cfg["vision_model"])
|
|
279
|
+
if seer:
|
|
280
|
+
line(
|
|
281
|
+
"Vision",
|
|
282
|
+
f"{seer} looks at screenshots and images" + ("" if seer == model else " (loaded when needed)"),
|
|
283
|
+
True,
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
|
|
276
287
|
if limits.get(model):
|
|
277
288
|
line(
|
|
278
289
|
"Limit",
|
|
@@ -321,6 +332,15 @@ def cmd_doctor(args) -> None:
|
|
|
321
332
|
except ValueError as e:
|
|
322
333
|
line("MCP", str(e), False)
|
|
323
334
|
ok = False
|
|
335
|
+
if cfg["sandbox"] == "off":
|
|
336
|
+
line("Sandbox", "off (lcode config set sandbox docker)", True)
|
|
337
|
+
else:
|
|
338
|
+
from lcode.sandbox import engine_problem
|
|
339
|
+
|
|
340
|
+
problem = engine_problem(cfg["sandbox"])
|
|
341
|
+
network = "network on" if cfg["sandbox_network"] else "no network"
|
|
342
|
+
line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
|
|
343
|
+
ok &= problem is None
|
|
324
344
|
node = shutil.which("npx")
|
|
325
345
|
line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
|
|
326
346
|
sys.exit(0 if ok else 1)
|
|
@@ -485,8 +505,21 @@ def cmd_chat(args) -> None:
|
|
|
485
505
|
search_backend=cfg["search_backend"],
|
|
486
506
|
searxng_url=cfg["searxng_url"],
|
|
487
507
|
checkpoints=cfg["checkpoints"],
|
|
508
|
+
sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
|
|
509
|
+
sandbox_image=cfg["sandbox_image"],
|
|
510
|
+
sandbox_network=cfg["sandbox_network"],
|
|
511
|
+
vision_model=cfg["vision_model"],
|
|
488
512
|
)
|
|
489
513
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
514
|
+
if agent.sandbox:
|
|
515
|
+
# Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
|
|
516
|
+
from lcode.sandbox import SandboxError
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
with console.status("Starting the sandbox…"):
|
|
520
|
+
agent.sandbox.ensure(cwd)
|
|
521
|
+
except SandboxError as e:
|
|
522
|
+
fail(f"the sandbox can't start: {e}\nTurn it off with: lcode config set sandbox off")
|
|
490
523
|
if not args.no_mcp:
|
|
491
524
|
from lcode.mcp.commands import start_session
|
|
492
525
|
|
|
@@ -496,6 +529,8 @@ def cmd_chat(args) -> None:
|
|
|
496
529
|
finally:
|
|
497
530
|
if agent.mcp:
|
|
498
531
|
agent.mcp.close()
|
|
532
|
+
if agent.sandbox:
|
|
533
|
+
agent.sandbox.stop()
|
|
499
534
|
|
|
500
535
|
|
|
501
536
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -522,6 +557,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
522
557
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
523
558
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
524
559
|
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
560
|
+
parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
|
|
525
561
|
parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
|
|
526
562
|
parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
|
|
527
563
|
return parser
|
lcode/config.py
CHANGED
|
@@ -25,6 +25,7 @@ PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
28
|
+
SANDBOX_ENGINES = ("off", "docker", "podman")
|
|
28
29
|
|
|
29
30
|
# key -> (default, type, help)
|
|
30
31
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -39,6 +40,10 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
39
40
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
40
41
|
"searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
|
|
41
42
|
"checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
|
|
43
|
+
"sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
|
|
44
|
+
"sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
|
|
45
|
+
"sandbox_network": (False, bool, "let commands in the sandbox use the network"),
|
|
46
|
+
"vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
|
|
42
47
|
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
43
48
|
}
|
|
44
49
|
ENV_OVERRIDES = {
|
|
@@ -48,6 +53,7 @@ ENV_OVERRIDES = {
|
|
|
48
53
|
"LCODE_KEEP_ALIVE": "keep_alive",
|
|
49
54
|
"OLLAMA_HOST": "ollama_host",
|
|
50
55
|
"LCODE_WEB": "web",
|
|
56
|
+
"LCODE_SANDBOX": "sandbox",
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
|
|
@@ -99,6 +105,11 @@ def coerce(key: str, value: object) -> object:
|
|
|
99
105
|
if value not in WEB_MODES:
|
|
100
106
|
raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
|
|
101
107
|
return value
|
|
108
|
+
if key == "sandbox":
|
|
109
|
+
value = {"none": "off", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
|
|
110
|
+
if value not in SANDBOX_ENGINES:
|
|
111
|
+
raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
|
|
112
|
+
return value
|
|
102
113
|
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
103
114
|
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
104
115
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
lcode/mcp/catalog.toml
CHANGED
|
@@ -130,6 +130,34 @@ requires = ["npx"]
|
|
|
130
130
|
setup = "Runs a headless browser; the first use may download it."
|
|
131
131
|
server = { command = "npx", args = ["-y", "@playwright/mcp@latest", "--headless"] }
|
|
132
132
|
|
|
133
|
+
[comfyui]
|
|
134
|
+
name = "ComfyUI"
|
|
135
|
+
description = "Generate and edit images with models you run locally in ComfyUI: FLUX, SDXL, SD 1.5, Qwen-Image"
|
|
136
|
+
homepage = "https://github.com/Comfy-Org/comfy-mcp"
|
|
137
|
+
requires = ["uvx"]
|
|
138
|
+
setup = """Needs ComfyUI on this machine (http://127.0.0.1:8188) with at least one image model:
|
|
139
|
+
uv tool install comfy-cli && comfy install
|
|
140
|
+
comfy launch --background -- --disable-smart-memory
|
|
141
|
+
--disable-smart-memory makes ComfyUI give the GPU back after each image. Before generating, lcode
|
|
142
|
+
unloads its own model so ComfyUI has the GPU; it reloads afterwards. Only the local tools are on;
|
|
143
|
+
Comfy Cloud's partner tools are left out, so nothing leaves this machine."""
|
|
144
|
+
|
|
145
|
+
[comfyui.server]
|
|
146
|
+
command = "uvx"
|
|
147
|
+
args = ["--python", "3.12", "--from", "comfy-mcp", "--with", "comfy-cli>=1.14.0", "comfy-mcp"]
|
|
148
|
+
env = { COMFYUI_URL = "${COMFYUI_URL}" }
|
|
149
|
+
tools = [
|
|
150
|
+
"server_info", "generate_image", "run_workflow", "run_template", "search_templates", "get_template", "fetch_template",
|
|
151
|
+
"list_workflow_slots", "set_workflow_slot", "job", "fetch_outputs", "upload_file", "search_models",
|
|
152
|
+
"download_model", "system_stats", "free_memory", "launch_comfyui",
|
|
153
|
+
]
|
|
154
|
+
free_gpu = ["generate_image", "run_workflow", "run_template"]
|
|
155
|
+
|
|
156
|
+
[[comfyui.inputs]]
|
|
157
|
+
var = "COMFYUI_URL"
|
|
158
|
+
prompt = "ComfyUI address (Enter for this machine, 127.0.0.1:8188)"
|
|
159
|
+
optional = true
|
|
160
|
+
|
|
133
161
|
[context7]
|
|
134
162
|
name = "Context7"
|
|
135
163
|
description = "Current documentation and code examples for thousands of libraries"
|
lcode/mcp/config.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Both use the `mcpServers` format that most MCP servers document, so a snippet from a server's README
|
|
4
4
|
can be pasted in as is. lcode adds a few optional keys per server: `disabled`, `tools` (only offer
|
|
5
|
-
these tools to the model), `allow` (tools that don't need approval), `timeout` and
|
|
5
|
+
these tools to the model), `allow` (tools that don't need approval), `timeout`, `oauth` and
|
|
6
|
+
`free_gpu` (tools that need the GPU to themselves, so lcode unloads its model first).
|
|
6
7
|
Values can refer to environment variables as ${NAME} or ${NAME:-default}.
|
|
7
8
|
"""
|
|
8
9
|
|
|
@@ -69,6 +70,7 @@ class ServerConfig:
|
|
|
69
70
|
timeout: float = DEFAULT_TIMEOUT
|
|
70
71
|
oauth: dict = field(default_factory=dict)
|
|
71
72
|
disabled: bool = False
|
|
73
|
+
free_gpu: list[str] | bool = False # tools (or all, if True) that need lcode's model off the GPU
|
|
72
74
|
error: str = "" # a problem with the settings; the server can't start
|
|
73
75
|
|
|
74
76
|
@property
|
|
@@ -121,6 +123,8 @@ def parse(name: str, raw: dict, source: str = "user") -> ServerConfig:
|
|
|
121
123
|
cfg.error = f"the environment variable {e} isn't set"
|
|
122
124
|
tools = raw.get("tools")
|
|
123
125
|
cfg.tools = [str(t) for t in tools] if isinstance(tools, list) else None
|
|
126
|
+
free_gpu = raw.get("free_gpu", False)
|
|
127
|
+
cfg.free_gpu = [str(t) for t in free_gpu] if isinstance(free_gpu, list) else bool(free_gpu)
|
|
124
128
|
cfg.allow = [str(t) for t in raw.get("allow") or []] if isinstance(raw.get("allow"), list) else []
|
|
125
129
|
try:
|
|
126
130
|
cfg.timeout = float(raw.get("timeout", DEFAULT_TIMEOUT))
|
lcode/mcp/manager.py
CHANGED
|
@@ -317,15 +317,20 @@ class McpManager:
|
|
|
317
317
|
raise McpError("`arguments` must be a JSON object")
|
|
318
318
|
return found[0], found[1], arguments
|
|
319
319
|
|
|
320
|
+
def needs_gpu(self, state: ServerState, tool: dict) -> bool:
|
|
321
|
+
"""Whether lcode should free the GPU (unload its model) before this tool runs."""
|
|
322
|
+
wanted = state.cfg.free_gpu
|
|
323
|
+
return wanted is True or (isinstance(wanted, list) and tool["name"] in wanted)
|
|
324
|
+
|
|
320
325
|
def allowed(self, state: ServerState, tool: dict) -> bool:
|
|
321
326
|
"""Tools the user allowed in mcp.json run without asking."""
|
|
322
327
|
return "*" in state.cfg.allow or tool["name"] in state.cfg.allow
|
|
323
328
|
|
|
324
|
-
def call(self, state: ServerState, tool: dict, arguments: dict) -> str:
|
|
329
|
+
def call(self, state: ServerState, tool: dict, arguments: dict, image_text=None) -> str:
|
|
325
330
|
try:
|
|
326
331
|
if state.conn is None:
|
|
327
332
|
raise TransportError("the server isn't connected")
|
|
328
|
-
return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
|
|
333
|
+
return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
|
|
329
334
|
except AuthRequired:
|
|
330
335
|
state.status = "login"
|
|
331
336
|
state.error = f"sign-in expired: run /mcp login {state.name}"
|
|
@@ -336,7 +341,7 @@ class McpManager:
|
|
|
336
341
|
self.restart(state.name)
|
|
337
342
|
if state.status == "ready" and state.conn:
|
|
338
343
|
try:
|
|
339
|
-
return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
|
|
344
|
+
return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
|
|
340
345
|
except McpError as again:
|
|
341
346
|
return f"Error: {again}"
|
|
342
347
|
return f"Error: {state.name}: {e}"
|
lcode/mcp/protocol.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import base64
|
|
11
11
|
import json
|
|
12
12
|
import re
|
|
13
|
+
from collections.abc import Callable
|
|
13
14
|
|
|
14
15
|
from lcode import __version__
|
|
15
16
|
|
|
@@ -99,8 +100,8 @@ def param_headers(schema: dict, arguments: dict) -> dict[str, str] | None:
|
|
|
99
100
|
return headers if walk(schema, arguments) else None
|
|
100
101
|
|
|
101
102
|
|
|
102
|
-
def result_text(result: dict) -> str:
|
|
103
|
-
"""Turn a tools/call result into text for the model."""
|
|
103
|
+
def result_text(result: dict, image_text: Callable[[str, str], str] | None = None) -> str:
|
|
104
|
+
"""Turn a tools/call result into text for the model. `image_text(data, mime)` describes images."""
|
|
104
105
|
parts = []
|
|
105
106
|
for item in result.get("content") or []:
|
|
106
107
|
if not isinstance(item, dict):
|
|
@@ -108,6 +109,8 @@ def result_text(result: dict) -> str:
|
|
|
108
109
|
kind = item.get("type")
|
|
109
110
|
if kind == "text":
|
|
110
111
|
parts.append(str(item.get("text", "")))
|
|
112
|
+
elif kind == "image" and image_text and item.get("data"):
|
|
113
|
+
parts.append(image_text(item["data"], item.get("mimeType", "image/png")))
|
|
111
114
|
elif kind in ("image", "audio"):
|
|
112
115
|
parts.append(f"[{kind} ({item.get('mimeType', 'unknown type')}) not shown]")
|
|
113
116
|
elif kind == "resource_link":
|
lcode/ollama.py
CHANGED
|
@@ -68,6 +68,10 @@ class Ollama:
|
|
|
68
68
|
names.add(m["name"][: -len(":latest")])
|
|
69
69
|
return names
|
|
70
70
|
|
|
71
|
+
def chat(self, payload: dict, timeout: float = 900) -> dict:
|
|
72
|
+
"""One chat request without streaming."""
|
|
73
|
+
return self._post("/api/chat", {**payload, "stream": False}, timeout=timeout)
|
|
74
|
+
|
|
71
75
|
def show(self, model: str) -> dict:
|
|
72
76
|
return self._post("/api/show", {"model": model})
|
|
73
77
|
|
lcode/repl.py
CHANGED
|
@@ -39,6 +39,7 @@ COMMANDS = {
|
|
|
39
39
|
"/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
|
|
40
40
|
"/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
|
|
41
41
|
"/checkpoints": "List the requests that changed files, and which files",
|
|
42
|
+
"/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
|
|
42
43
|
"/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
|
|
43
44
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
44
45
|
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
@@ -143,6 +144,11 @@ def banner(agent: Agent) -> None:
|
|
|
143
144
|
f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
|
|
144
145
|
f"[dim]web[/] {web_status(agent)}\n"
|
|
145
146
|
+ (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
|
|
147
|
+
+ (
|
|
148
|
+
f"[dim]sandbox[/] {escape(agent.sandbox.describe())} [dim](/sandbox)[/]\n"
|
|
149
|
+
if agent.sandbox
|
|
150
|
+
else ""
|
|
151
|
+
)
|
|
146
152
|
+ "\n"
|
|
147
153
|
"[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
|
|
148
154
|
),
|
|
@@ -240,6 +246,31 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
240
246
|
apply_context(agent, size, hardware)
|
|
241
247
|
|
|
242
248
|
|
|
249
|
+
def sandbox_command(agent: Agent, arg: str) -> None:
|
|
250
|
+
c, sandbox = agent.console, agent.sandbox
|
|
251
|
+
if sandbox is None:
|
|
252
|
+
c.print(
|
|
253
|
+
"The sandbox is off: shell commands run directly on this machine. Start lcode with --sandbox, or "
|
|
254
|
+
"turn it on for every session with: lcode config set sandbox docker"
|
|
255
|
+
)
|
|
256
|
+
return
|
|
257
|
+
words = arg.split()
|
|
258
|
+
if len(words) == 2 and words[0] == "network" and words[1] in ("on", "off"):
|
|
259
|
+
sandbox.network = words[1] == "on"
|
|
260
|
+
sandbox.stop() # the next command starts a container with the new setting
|
|
261
|
+
c.print(f"Network access in the sandbox is {words[1]} from the next command.")
|
|
262
|
+
return
|
|
263
|
+
if arg:
|
|
264
|
+
c.print("[yellow]Use /sandbox or /sandbox network on|off.[/]")
|
|
265
|
+
return
|
|
266
|
+
where = f" · container {sandbox.container}, mounting {sandbox.root}" if sandbox.container else ""
|
|
267
|
+
c.print(f"Sandbox: {escape(sandbox.describe())}{escape(where)}")
|
|
268
|
+
c.print(
|
|
269
|
+
"[dim]Shell commands run in the container and can only see this project; file tools are limited to it "
|
|
270
|
+
"too. In auto-edit mode, commands run without asking.[/]"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
243
274
|
def mcp_command(agent: Agent, arg: str) -> None:
|
|
244
275
|
from lcode.mcp.commands import status_table
|
|
245
276
|
from lcode.mcp.protocol import McpError
|
|
@@ -515,6 +546,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
515
546
|
print_checkpoints(agent)
|
|
516
547
|
elif cmd == "/mcp":
|
|
517
548
|
mcp_command(agent, arg)
|
|
549
|
+
elif cmd == "/sandbox":
|
|
550
|
+
sandbox_command(agent, arg)
|
|
518
551
|
elif cmd == "/compact":
|
|
519
552
|
agent.compact(arg)
|
|
520
553
|
elif cmd in ("/context", "/ctx"):
|
lcode/sandbox.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""An optional sandbox: the model's shell commands run in a container that only sees the project.
|
|
2
|
+
|
|
3
|
+
One container per session. The project (the git repository around the working directory, or the
|
|
4
|
+
working directory itself) is mounted at the same path, so paths in command output stay valid and
|
|
5
|
+
lcode's file tools, which run on the host, see the same files. Commands run as the host user, with
|
|
6
|
+
no network unless it's allowed, no Linux capabilities, a process limit, and nothing else from the
|
|
7
|
+
host: not the home folder, not the Docker socket.
|
|
8
|
+
|
|
9
|
+
The default image is built locally on first use from the Dockerfile below. Any image with bash and
|
|
10
|
+
setsid (util-linux or busybox) can be used instead (`sandbox_image`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import secrets
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
DOCKERFILE = """\
|
|
26
|
+
FROM python:3.12-slim-bookworm
|
|
27
|
+
RUN apt-get update \\
|
|
28
|
+
&& apt-get install -y --no-install-recommends \\
|
|
29
|
+
git curl ca-certificates build-essential ripgrep jq unzip less procps nodejs npm \\
|
|
30
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
31
|
+
"""
|
|
32
|
+
DEFAULT_IMAGE = f"lcode-sandbox:{hashlib.sha256(DOCKERFILE.encode()).hexdigest()[:12]}"
|
|
33
|
+
LABEL = "dev.lcode.sandbox"
|
|
34
|
+
HOME = "/tmp/lcode-home"
|
|
35
|
+
ENGINES = ("docker", "podman")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SandboxError(Exception):
|
|
39
|
+
"""The sandbox can't be used; the message says how to fix it."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def engine_problem(engine: str) -> str | None:
|
|
43
|
+
"""Why `engine` can't run containers here, or None if it can."""
|
|
44
|
+
if not shutil.which(engine):
|
|
45
|
+
if engine == "podman":
|
|
46
|
+
return "podman isn't installed (https://podman.io/docs/installation)"
|
|
47
|
+
if platform.system() == "Darwin":
|
|
48
|
+
return "docker isn't installed: install Docker Desktop, OrbStack or colima"
|
|
49
|
+
return "docker isn't installed (https://docs.docker.com/engine/install/)"
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(
|
|
52
|
+
[engine, "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=20
|
|
53
|
+
)
|
|
54
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
55
|
+
return f"{engine} doesn't respond: {e}"
|
|
56
|
+
if r.returncode != 0:
|
|
57
|
+
detail = (r.stderr.strip().splitlines() or [""])[-1]
|
|
58
|
+
if "permission denied" in detail.lower():
|
|
59
|
+
return (
|
|
60
|
+
f"{engine} needs permission: add yourself to the docker group "
|
|
61
|
+
"(sudo usermod -aG docker $USER, then log in again)"
|
|
62
|
+
)
|
|
63
|
+
if platform.system() == "Darwin":
|
|
64
|
+
return f"{engine} isn't running: start Docker Desktop, OrbStack or colima ({detail})"
|
|
65
|
+
return f"{engine} isn't running: start it (sudo systemctl start {engine}) ({detail})"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_orphans(engine: str) -> None:
|
|
70
|
+
"""Remove sandboxes left behind by lcode sessions that ended without cleaning up."""
|
|
71
|
+
try:
|
|
72
|
+
r = subprocess.run(
|
|
73
|
+
[engine, "ps", "-a", "--filter", f"label={LABEL}", "--format", '{{.ID}} {{.Label "dev.lcode.pid"}}'],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=20,
|
|
77
|
+
)
|
|
78
|
+
except (OSError, subprocess.SubprocessError):
|
|
79
|
+
return
|
|
80
|
+
stale = []
|
|
81
|
+
for line in r.stdout.split("\n"):
|
|
82
|
+
parts = line.split()
|
|
83
|
+
if len(parts) == 2 and parts[1].isdigit() and not _alive(int(parts[1])):
|
|
84
|
+
stale.append(parts[0])
|
|
85
|
+
if stale:
|
|
86
|
+
subprocess.run([engine, "rm", "-f", *stale], capture_output=True, timeout=60)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _alive(pid: int) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
os.kill(pid, 0)
|
|
92
|
+
except ProcessLookupError:
|
|
93
|
+
return False
|
|
94
|
+
except PermissionError:
|
|
95
|
+
return True
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Sandbox:
|
|
100
|
+
def __init__(self, engine: str, image: str | None = None, network: bool = False, console: Console | None = None):
|
|
101
|
+
if engine not in ENGINES:
|
|
102
|
+
raise SandboxError(f"unknown sandbox {engine!r}; use docker or podman")
|
|
103
|
+
self.engine = engine
|
|
104
|
+
self.image = image or DEFAULT_IMAGE
|
|
105
|
+
self.network = network
|
|
106
|
+
self.console = console or Console()
|
|
107
|
+
self.container: str | None = None
|
|
108
|
+
self.root: Path | None = None
|
|
109
|
+
|
|
110
|
+
# -- lifecycle
|
|
111
|
+
def check(self) -> None:
|
|
112
|
+
problem = engine_problem(self.engine)
|
|
113
|
+
if problem:
|
|
114
|
+
raise SandboxError(problem)
|
|
115
|
+
|
|
116
|
+
def contains(self, path: Path) -> bool:
|
|
117
|
+
return self.root is not None and (path == self.root or self.root in path.parents)
|
|
118
|
+
|
|
119
|
+
def ensure(self, cwd: Path) -> None:
|
|
120
|
+
"""Have a container running whose mounted folder includes `cwd`."""
|
|
121
|
+
if self.container and self.contains(cwd):
|
|
122
|
+
return
|
|
123
|
+
self.stop()
|
|
124
|
+
self.check()
|
|
125
|
+
remove_orphans(self.engine)
|
|
126
|
+
if self.image == DEFAULT_IMAGE:
|
|
127
|
+
self._build_default_image()
|
|
128
|
+
root = project_root(cwd)
|
|
129
|
+
name = f"lcode-sandbox-{os.getpid()}-{secrets.token_hex(3)}"
|
|
130
|
+
command = [
|
|
131
|
+
self.engine, "run", "--detach", "--init", "--name", name,
|
|
132
|
+
"--label", LABEL, "--label", f"dev.lcode.pid={os.getpid()}",
|
|
133
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "1024",
|
|
134
|
+
"--volume", f"{root}:{root}", "--workdir", str(cwd),
|
|
135
|
+
"--env", f"HOME={HOME}", "--tmpfs", f"{HOME}:exec,mode=1777",
|
|
136
|
+
] # fmt: skip
|
|
137
|
+
if not self.network:
|
|
138
|
+
command += ["--network", "none"]
|
|
139
|
+
if self.engine == "podman":
|
|
140
|
+
command += ["--userns", "keep-id"] # rootless podman: files belong to the host user
|
|
141
|
+
else:
|
|
142
|
+
command += ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
143
|
+
command += [self.image, "sleep", "infinity"]
|
|
144
|
+
r = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
raise SandboxError(f"couldn't start the sandbox: {(r.stderr.strip().splitlines() or ['?'])[-1]}")
|
|
147
|
+
self.container, self.root = name, root
|
|
148
|
+
|
|
149
|
+
def _build_default_image(self) -> None:
|
|
150
|
+
exists = subprocess.run([self.engine, "image", "inspect", self.image], capture_output=True, timeout=30)
|
|
151
|
+
if exists.returncode == 0:
|
|
152
|
+
return
|
|
153
|
+
with self.console.status("Building the sandbox image (once; a minute or two)…"):
|
|
154
|
+
r = subprocess.run(
|
|
155
|
+
[self.engine, "build", "--tag", self.image, "-"],
|
|
156
|
+
input=DOCKERFILE,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
timeout=1800,
|
|
160
|
+
)
|
|
161
|
+
if r.returncode != 0:
|
|
162
|
+
tail = "\n".join(r.stderr.strip().splitlines()[-5:])
|
|
163
|
+
raise SandboxError(f"couldn't build the sandbox image:\n{tail}")
|
|
164
|
+
|
|
165
|
+
def exec_argv(self, cwd: Path, script: str) -> tuple[list[str], str]:
|
|
166
|
+
"""The command that runs `script` with bash inside the container, and a token for kill()."""
|
|
167
|
+
assert self.container is not None
|
|
168
|
+
token = secrets.token_hex(6)
|
|
169
|
+
# Its own session, so a timeout or Ctrl+C can stop everything the command started.
|
|
170
|
+
script = f"echo $$ > /tmp/.lcode-{token}.pgid\n{script}"
|
|
171
|
+
argv = [self.engine, "exec", "--interactive", "--workdir", str(cwd), self.container]
|
|
172
|
+
return [*argv, "setsid", "--wait", "bash", "-c", script], token
|
|
173
|
+
|
|
174
|
+
def kill(self, token: str) -> None:
|
|
175
|
+
"""Stop a command and everything it started (after a timeout or Ctrl+C); the container stays."""
|
|
176
|
+
if self.container:
|
|
177
|
+
pgid = f"$(cat /tmp/.lcode-{token}.pgid 2>/dev/null)"
|
|
178
|
+
subprocess.run(
|
|
179
|
+
# bash, not sh: dash's kill doesn't accept "--" before a negative (process group) id
|
|
180
|
+
[self.engine, "exec", self.container, "bash", "-c", f'[ -n "{pgid}" ] && kill -KILL -- -{pgid}'],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
timeout=30,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def stop(self) -> None:
|
|
186
|
+
if self.container:
|
|
187
|
+
subprocess.run([self.engine, "rm", "--force", self.container], capture_output=True, timeout=60)
|
|
188
|
+
self.container, self.root = None, None
|
|
189
|
+
|
|
190
|
+
def describe(self) -> str:
|
|
191
|
+
image = "lcode's image" if self.image == DEFAULT_IMAGE else self.image
|
|
192
|
+
network = "network on" if self.network else "no network"
|
|
193
|
+
return f"{self.engine} · {image} · {network}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def project_root(cwd: Path) -> Path:
|
|
197
|
+
"""The folder the sandbox mounts: the git repository around `cwd`, or `cwd` itself."""
|
|
198
|
+
from lcode.checkpoints import work_tree_for
|
|
199
|
+
|
|
200
|
+
root = work_tree_for(cwd)
|
|
201
|
+
home = Path.home().resolve()
|
|
202
|
+
if root == home or root in home.parents or root == Path("/"):
|
|
203
|
+
raise SandboxError(f"won't mount {root} into the sandbox; start lcode in a project folder")
|
|
204
|
+
return root
|
lcode/tools.py
CHANGED
|
@@ -9,11 +9,10 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import queue
|
|
11
11
|
import re
|
|
12
|
-
import
|
|
12
|
+
import secrets
|
|
13
13
|
import shutil
|
|
14
14
|
import signal
|
|
15
15
|
import subprocess
|
|
16
|
-
import tempfile
|
|
17
16
|
import threading
|
|
18
17
|
import time
|
|
19
18
|
from pathlib import Path
|
|
@@ -25,10 +24,16 @@ from rich.text import Text
|
|
|
25
24
|
|
|
26
25
|
from lcode import web
|
|
27
26
|
from lcode.permissions import bash_key, is_read_only
|
|
27
|
+
from lcode.sandbox import SandboxError
|
|
28
|
+
from lcode.vision import is_image
|
|
28
29
|
|
|
29
30
|
if TYPE_CHECKING:
|
|
30
31
|
from lcode.agent import Agent
|
|
31
32
|
|
|
33
|
+
NO_NETWORK = re.compile(
|
|
34
|
+
r"Network is unreachable|Temporary failure in name resolution|Could not resolve host|getaddrinfo|ENOTFOUND|"
|
|
35
|
+
r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
|
|
36
|
+
)
|
|
32
37
|
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
33
38
|
IGNORE_DIRS = {
|
|
34
39
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
@@ -158,7 +163,17 @@ WEB_FETCH_SCHEMA = _fn(
|
|
|
158
163
|
},
|
|
159
164
|
["url"],
|
|
160
165
|
)
|
|
161
|
-
|
|
166
|
+
VIEW_IMAGE_SCHEMA = _fn(
|
|
167
|
+
"view_image",
|
|
168
|
+
"Look at an image file (screenshot, diagram, photo, mockup): returns a detailed description with all visible "
|
|
169
|
+
"text transcribed. Ask a specific question to focus it.",
|
|
170
|
+
{
|
|
171
|
+
"path": {"type": "string", "description": "Image file: png, jpg, webp or gif"},
|
|
172
|
+
"question": {"type": "string", "description": "What you need to know from the image (optional)"},
|
|
173
|
+
},
|
|
174
|
+
["path"],
|
|
175
|
+
)
|
|
176
|
+
TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA]}
|
|
162
177
|
|
|
163
178
|
|
|
164
179
|
# ----------------------------------------------------------------------------- helpers
|
|
@@ -277,6 +292,14 @@ class Toolbox:
|
|
|
277
292
|
p = Path(os.path.expanduser(path or "."))
|
|
278
293
|
return (p if p.is_absolute() else self.agent.cwd / p).resolve()
|
|
279
294
|
|
|
295
|
+
def path(self, path: str | None) -> Path:
|
|
296
|
+
"""Resolve a path from the model; with the sandbox on, it must be inside the project."""
|
|
297
|
+
p = self.resolve(path)
|
|
298
|
+
root = self.agent.sandbox_root()
|
|
299
|
+
if root is not None and p != root and root not in p.parents:
|
|
300
|
+
raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
|
|
301
|
+
return p
|
|
302
|
+
|
|
280
303
|
def rel(self, p: Path) -> str:
|
|
281
304
|
try:
|
|
282
305
|
return str(p.relative_to(self.agent.cwd)) or "."
|
|
@@ -317,6 +340,11 @@ class Toolbox:
|
|
|
317
340
|
valid = ", ".join(p if params[p].default is inspect.Parameter.empty else f"{p} (optional)" for p in params)
|
|
318
341
|
return f"{'; '.join(parts)}. Valid arguments: {valid}."
|
|
319
342
|
|
|
343
|
+
# -- images
|
|
344
|
+
def t_view_image(self, path: str, question: str = "") -> str:
|
|
345
|
+
p = self.path(path)
|
|
346
|
+
return f"[{self.rel(p)}, as described by {self.agent.vision_model()}]\n" + self.agent.look(p, question)
|
|
347
|
+
|
|
320
348
|
# -- MCP servers
|
|
321
349
|
def _mcp(self, name: str, args: dict) -> str:
|
|
322
350
|
mcp = self.agent.mcp
|
|
@@ -336,7 +364,13 @@ class Toolbox:
|
|
|
336
364
|
ok, feedback = self.agent.perms.request(f"mcp:{state.name}:{tool['name']}", "mcp", title, body)
|
|
337
365
|
if not ok:
|
|
338
366
|
return feedback
|
|
339
|
-
|
|
367
|
+
if mcp.needs_gpu(state, tool):
|
|
368
|
+
freed = self.agent.free_gpu()
|
|
369
|
+
if freed:
|
|
370
|
+
self.console.print(
|
|
371
|
+
Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
|
|
372
|
+
)
|
|
373
|
+
result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
|
|
340
374
|
if result.startswith("Error:"):
|
|
341
375
|
return result
|
|
342
376
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s) from {state.name}", style="dim"))
|
|
@@ -344,11 +378,13 @@ class Toolbox:
|
|
|
344
378
|
|
|
345
379
|
# -- read-only
|
|
346
380
|
def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
|
347
|
-
p = self.
|
|
381
|
+
p = self.path(path)
|
|
348
382
|
if not p.exists():
|
|
349
383
|
raise ToolError(f"{path} does not exist")
|
|
350
384
|
if p.is_dir():
|
|
351
385
|
raise ToolError(f"{path} is a directory; use list_dir")
|
|
386
|
+
if is_image(p):
|
|
387
|
+
raise ToolError(f"{path} is an image; look at it with view_image")
|
|
352
388
|
if is_binary(p):
|
|
353
389
|
raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
|
|
354
390
|
lines = p.read_text(errors="replace").splitlines()
|
|
@@ -364,13 +400,13 @@ class Toolbox:
|
|
|
364
400
|
return truncate(out, 120_000)
|
|
365
401
|
|
|
366
402
|
def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
|
|
367
|
-
p = self.
|
|
403
|
+
p = self.path(path)
|
|
368
404
|
if not p.is_dir():
|
|
369
405
|
raise ToolError(f"{path} is not a directory")
|
|
370
406
|
return tree(p, depth=max(1, min(int(depth or 2), 6)))
|
|
371
407
|
|
|
372
408
|
def t_glob(self, pattern: str, path: str = ".") -> str:
|
|
373
|
-
base = self.
|
|
409
|
+
base = self.path(path)
|
|
374
410
|
pattern = pattern.removeprefix("./")
|
|
375
411
|
patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
|
|
376
412
|
hits = [
|
|
@@ -385,7 +421,7 @@ class Toolbox:
|
|
|
385
421
|
def t_grep(
|
|
386
422
|
self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
|
|
387
423
|
) -> str:
|
|
388
|
-
p = self.
|
|
424
|
+
p = self.path(path)
|
|
389
425
|
if shutil.which("rg"):
|
|
390
426
|
cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
|
|
391
427
|
cmd += ["--max-columns", "400"]
|
|
@@ -450,7 +486,7 @@ class Toolbox:
|
|
|
450
486
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
451
487
|
|
|
452
488
|
def t_write_file(self, path: str, content: str) -> str:
|
|
453
|
-
p = self.
|
|
489
|
+
p = self.path(path)
|
|
454
490
|
exists = p.exists()
|
|
455
491
|
if exists:
|
|
456
492
|
if p.is_dir():
|
|
@@ -472,7 +508,7 @@ class Toolbox:
|
|
|
472
508
|
return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
|
|
473
509
|
|
|
474
510
|
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
475
|
-
p = self.
|
|
511
|
+
p = self.path(path)
|
|
476
512
|
if not p.exists():
|
|
477
513
|
raise ToolError(f"{path} does not exist (use write_file to create it)")
|
|
478
514
|
self._check_fresh(p)
|
|
@@ -515,20 +551,30 @@ class Toolbox:
|
|
|
515
551
|
|
|
516
552
|
# -- shell
|
|
517
553
|
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
518
|
-
|
|
554
|
+
sandbox = self.agent.sandbox
|
|
555
|
+
if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit"):
|
|
519
556
|
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
)
|
|
557
|
+
where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
|
|
558
|
+
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body)
|
|
523
559
|
if not ok:
|
|
524
560
|
return feedback
|
|
561
|
+
if not is_read_only(command):
|
|
525
562
|
self.agent.checkpoint()
|
|
526
563
|
self.console.print(Text(f" $ {command}", style="bold cyan"))
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
564
|
+
marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
|
|
565
|
+
script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
|
|
566
|
+
token = ""
|
|
567
|
+
if sandbox:
|
|
568
|
+
try:
|
|
569
|
+
with self.console.status("Starting the sandbox…"):
|
|
570
|
+
sandbox.ensure(self.agent.cwd)
|
|
571
|
+
except SandboxError as e:
|
|
572
|
+
return f"Error: the sandbox can't start, so the command didn't run: {e}"
|
|
573
|
+
argv, token = sandbox.exec_argv(self.agent.cwd, script)
|
|
574
|
+
else:
|
|
575
|
+
argv = ["bash", "-c", script]
|
|
530
576
|
proc = subprocess.Popen(
|
|
531
|
-
|
|
577
|
+
argv,
|
|
532
578
|
cwd=self.agent.cwd,
|
|
533
579
|
stdout=subprocess.PIPE,
|
|
534
580
|
stderr=subprocess.STDOUT,
|
|
@@ -546,9 +592,14 @@ class Toolbox:
|
|
|
546
592
|
lines.put(line)
|
|
547
593
|
lines.put(None)
|
|
548
594
|
|
|
595
|
+
def stop() -> None:
|
|
596
|
+
if sandbox and token:
|
|
597
|
+
sandbox.kill(token) # the command runs in the container, not under our process
|
|
598
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
599
|
+
|
|
549
600
|
threading.Thread(target=pump, daemon=True).start()
|
|
550
601
|
out: list[str] = []
|
|
551
|
-
shown, status = 0, ""
|
|
602
|
+
shown, status, recorded = 0, "", ""
|
|
552
603
|
deadline = time.time() + int(timeout or 180)
|
|
553
604
|
try:
|
|
554
605
|
while True:
|
|
@@ -556,30 +607,37 @@ class Toolbox:
|
|
|
556
607
|
line = lines.get(timeout=0.2)
|
|
557
608
|
except queue.Empty:
|
|
558
609
|
if time.time() > deadline:
|
|
559
|
-
|
|
610
|
+
stop()
|
|
560
611
|
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
561
612
|
break
|
|
562
613
|
continue
|
|
563
614
|
if line is None:
|
|
564
615
|
break
|
|
616
|
+
if line.startswith(marker):
|
|
617
|
+
recorded = line[len(marker) :].strip()
|
|
618
|
+
continue
|
|
565
619
|
out.append(line)
|
|
566
620
|
if shown <= 40:
|
|
567
621
|
msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
|
|
568
622
|
self.console.print(Text(msg, style="dim"))
|
|
569
623
|
shown += 1
|
|
570
624
|
except KeyboardInterrupt:
|
|
571
|
-
|
|
625
|
+
stop()
|
|
572
626
|
raise
|
|
573
627
|
code = proc.wait()
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
628
|
+
if out and out[-1] == "\n":
|
|
629
|
+
out.pop() # the blank line printed before the marker
|
|
630
|
+
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
631
|
+
if sandbox and not sandbox.contains(Path(recorded).resolve()):
|
|
632
|
+
status += "\n[That directory is outside the project; the working directory stays the same]"
|
|
633
|
+
else:
|
|
577
634
|
self.agent.cwd = Path(recorded).resolve()
|
|
578
635
|
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
636
|
+
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(out[-40:])):
|
|
637
|
+
status += (
|
|
638
|
+
"\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
|
|
639
|
+
"with /sandbox network on]"
|
|
640
|
+
)
|
|
583
641
|
self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
|
|
584
642
|
return truncate("".join(out)) + status + f"\n[exit code: {code}]"
|
|
585
643
|
|
lcode/vision.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Looking at images: screenshots, diagrams and photos the user attaches or the model opens.
|
|
2
|
+
|
|
3
|
+
The model that runs the conversation often can't see: lcode's text-only variants leave out the
|
|
4
|
+
vision projector to save GPU memory. So lcode asks a model that can see to describe the image in
|
|
5
|
+
detail (all text transcribed, layout, colors), with the user's question in mind, and gives the
|
|
6
|
+
description to the conversation. Which model looks (`vision_model = "auto"`):
|
|
7
|
+
|
|
8
|
+
1. the session's model, if it has vision;
|
|
9
|
+
2. otherwise the original Ollama model behind a text-only catalog variant (the same weights plus
|
|
10
|
+
the vision projector; `lcode setup` already downloaded it), e.g. qwen3.6:35b-a3b-coding;
|
|
11
|
+
3. otherwise none, unless `vision_model` names one (for example qwen3-vl:8b).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from lcode import catalog
|
|
20
|
+
from lcode.ollama import Ollama, OllamaError
|
|
21
|
+
|
|
22
|
+
IMAGE_TYPES = {
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".gif": "image/gif",
|
|
28
|
+
}
|
|
29
|
+
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
30
|
+
DESCRIBE_CONTEXT = 16384 # enough for one image and a long description
|
|
31
|
+
|
|
32
|
+
PROMPT = """You are the eyes of a coding assistant that cannot see images. Describe this image so the \
|
|
33
|
+
assistant can act on it without seeing it.
|
|
34
|
+
- Transcribe all visible text exactly: code, error messages, terminal output, labels, menu items, numbers.
|
|
35
|
+
- Describe the layout and every important element: what it is, where it is, its size, color and state \
|
|
36
|
+
(enabled, selected, highlighted, misaligned, overlapping, cut off).
|
|
37
|
+
- For diagrams, charts and architecture drawings, describe the parts and how they connect.
|
|
38
|
+
- Say what looks wrong or unusual, if anything.
|
|
39
|
+
Don't guess at what isn't visible.{question}"""
|
|
40
|
+
|
|
41
|
+
INSTALL_HINT = (
|
|
42
|
+
"no model that can see images is available: run `ollama pull qwen3-vl:8b` and "
|
|
43
|
+
"`lcode config set vision_model qwen3-vl:8b`, or use a catalog model (lcode setup)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VisionError(Exception):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_image(path: Path) -> bool:
|
|
52
|
+
return path.suffix.lower() in IMAGE_TYPES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def can_see(ollama: Ollama, model: str) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
return "vision" in (ollama.show(model).get("capabilities") or [])
|
|
58
|
+
except (OllamaError, AttributeError, TypeError):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pick_model(ollama: Ollama, model: str, setting: str) -> str | None:
|
|
63
|
+
"""The model that looks at images for a session running `model` (see the module docstring)."""
|
|
64
|
+
if setting == "off":
|
|
65
|
+
return None
|
|
66
|
+
if setting != "auto":
|
|
67
|
+
return setting
|
|
68
|
+
if can_see(ollama, model):
|
|
69
|
+
return model
|
|
70
|
+
spec = catalog.find(model)
|
|
71
|
+
if spec and spec.tag != model and can_see(ollama, spec.tag):
|
|
72
|
+
return spec.tag
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_image(path: Path) -> bytes:
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
raise VisionError(f"{path} doesn't exist")
|
|
79
|
+
if not is_image(path):
|
|
80
|
+
raise VisionError(f"{path.name} isn't an image lcode can read ({', '.join(sorted(IMAGE_TYPES))})")
|
|
81
|
+
size = path.stat().st_size
|
|
82
|
+
if size > MAX_IMAGE_BYTES:
|
|
83
|
+
raise VisionError(f"{path.name} is {size // (1024 * 1024)} MB; images up to 20 MB are supported")
|
|
84
|
+
return path.read_bytes()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def describe(ollama: Ollama, model: str, image: bytes, question: str = "", options: dict | None = None,
|
|
88
|
+
keep_alive: str = "30m") -> str: # fmt: skip
|
|
89
|
+
"""A detailed description of the image, focused on the question if there is one."""
|
|
90
|
+
focus = (
|
|
91
|
+
f"\n\nThe user asks: {question.strip()}\nPay special attention to what that needs." if question.strip() else ""
|
|
92
|
+
)
|
|
93
|
+
payload = {
|
|
94
|
+
"model": model,
|
|
95
|
+
"messages": [
|
|
96
|
+
{"role": "user", "content": PROMPT.format(question=focus), "images": [base64.b64encode(image).decode()]}
|
|
97
|
+
],
|
|
98
|
+
"think": False,
|
|
99
|
+
"keep_alive": keep_alive,
|
|
100
|
+
"options": options or {"num_ctx": DESCRIBE_CONTEXT},
|
|
101
|
+
}
|
|
102
|
+
try:
|
|
103
|
+
reply = ollama.chat(payload)
|
|
104
|
+
except OllamaError as e:
|
|
105
|
+
raise VisionError(f"{model} couldn't look at the image: {e}") from e
|
|
106
|
+
text = ((reply.get("message") or {}).get("content") or "").strip()
|
|
107
|
+
if not text:
|
|
108
|
+
raise VisionError(f"{model} returned no description")
|
|
109
|
+
return text
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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/
|
|
@@ -78,13 +78,18 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
78
78
|
real coding tasks on your machine.
|
|
79
79
|
- **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
|
|
80
80
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
81
|
+
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
82
|
+
Podman) that only sees your project, without network access unless you allow it.
|
|
81
83
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
82
84
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
83
85
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
84
86
|
with tool calling.
|
|
85
87
|
- **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
|
|
86
88
|
Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes and more with one command
|
|
87
|
-
(`lcode mcp add atlassian`), or any other MCP server. Browser sign-in (OAuth) is built in.
|
|
89
|
+
(`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
|
|
90
|
+
- **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
|
|
91
|
+
vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
|
|
92
|
+
images with local models such as FLUX, SDXL and Qwen-Image.
|
|
88
93
|
- **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
|
|
89
94
|
search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
|
|
90
95
|
- **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
|
|
@@ -184,7 +189,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
184
189
|
|
|
185
190
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
186
191
|
|
|
187
|
-
- An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
188
192
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
189
193
|
|
|
190
194
|
## Contributing
|
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=FWZV2PnebwvlC4fL-VRqLfpH2njKjqCUWaGd0s-R-5c,117
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=AhUf1l0OEjpizU74HI7yfdRz0q_UAnqFddbyQwdmFx0,32256
|
|
4
4
|
lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
|
|
5
5
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
6
6
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
7
|
-
lcode/cli.py,sha256=
|
|
8
|
-
lcode/config.py,sha256=
|
|
7
|
+
lcode/cli.py,sha256=53MucKCHe9NreYXwDaMEwfHtPS-26kxj7Xg3Bl4yoR8,27506
|
|
8
|
+
lcode/config.py,sha256=VyRHq3ElCGmTQqTdtcGRHGuselm2U8-04ZC3tnEQ6L8,7063
|
|
9
9
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
10
10
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
11
11
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
12
|
-
lcode/ollama.py,sha256
|
|
12
|
+
lcode/ollama.py,sha256=8zxJcMhcxKF3L0vdgrzcoXAj_4MvbybVDSRPbLek0QE,6892
|
|
13
13
|
lcode/permissions.py,sha256=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
|
|
14
14
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
15
|
-
lcode/repl.py,sha256=
|
|
15
|
+
lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
|
|
16
|
+
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
16
17
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
17
|
-
lcode/tools.py,sha256=
|
|
18
|
+
lcode/tools.py,sha256=iwt7UINg7xkwz1k_BAXFM-siA6KxC6LHuAjaomBHoRU,30287
|
|
19
|
+
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
18
20
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
19
21
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
20
22
|
lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
|
|
21
23
|
lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
|
|
22
|
-
lcode/mcp/catalog.toml,sha256=
|
|
24
|
+
lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
|
|
23
25
|
lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
|
|
24
26
|
lcode/mcp/commands.py,sha256=Rlnc-LFydRjHTiW-CSxjmQqX0d2MSiEViLF9kczYIi4,16420
|
|
25
|
-
lcode/mcp/config.py,sha256=
|
|
27
|
+
lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
|
|
26
28
|
lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
|
|
27
|
-
lcode/mcp/manager.py,sha256=
|
|
28
|
-
lcode/mcp/protocol.py,sha256
|
|
29
|
+
lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
|
|
30
|
+
lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
|
|
29
31
|
lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
30
|
-
lcode_cli-0.
|
|
31
|
-
lcode_cli-0.
|
|
32
|
-
lcode_cli-0.
|
|
33
|
-
lcode_cli-0.
|
|
34
|
-
lcode_cli-0.
|
|
32
|
+
lcode_cli-0.6.0.dist-info/METADATA,sha256=oMYnPQJlwjwLZls7GP4mxJkTRLmWM0LaVzAsyQdcqiM,10251
|
|
33
|
+
lcode_cli-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
34
|
+
lcode_cli-0.6.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
|
|
35
|
+
lcode_cli-0.6.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
36
|
+
lcode_cli-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|