lcode-cli 0.23.0__py3-none-any.whl → 0.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lcode/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.23.0"
6
+ __version__ = "0.24.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -11,6 +11,7 @@ import shutil
11
11
  import subprocess
12
12
  import threading
13
13
  import time
14
+ import urllib.parse
14
15
  import uuid
15
16
  from collections.abc import Callable
16
17
  from dataclasses import dataclass
@@ -63,6 +64,15 @@ MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren
63
64
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
64
65
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
65
66
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
+ # @path, @"path with spaces" or @'path'
68
+ MENTION = re.compile(r"""(?<!\S)@(?:"([^"\n]+)"|'([^'\n]+)'|([\w./~\-]+))""")
69
+ # An image path written in a message: in quotes, or without spaces (a space escaped as "\ " is fine), or as a
70
+ # file:// address, the way terminals paste a dragged file.
71
+ IMAGE_PATH = re.compile(
72
+ r"""(['"])((?:file://)?[^'"\n]+?\.(?:png|jpe?g|webp|gif))\1"""
73
+ r"""|(?<![^\s(])((?:[^\s\\'"(]|\\ )+?\.(?:png|jpe?g|webp|gif))(?=[\s,;:!?)\]]|\.(?:\s|$)|$)""",
74
+ re.IGNORECASE,
75
+ )
66
76
  PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
67
77
 
68
78
  SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
@@ -965,30 +975,68 @@ class Agent:
965
975
  )
966
976
 
967
977
  def expand_mentions(self, text: str) -> str:
968
- """Inline files referenced as @path in the user's message; @name of an agent asks for that agent."""
969
- attached = []
970
- for ref in re.findall(r"(?<!\S)@([\w./~\-]+)", text):
978
+ """Attach what the user's message points to: files and folders written as @path, image paths (with or
979
+ without @), and @name of an agent asks for that agent. An image path that doesn't exist is reported to
980
+ the user and the model, so the model doesn't guess what the image shows."""
981
+ attached: list[str] = []
982
+ seen: set[Path] = set()
983
+ question = MENTION.sub("", text)
984
+
985
+ def attach_image(p: Path) -> None:
986
+ try:
987
+ description = self.look(p, question)
988
+ who = f' described_by="{self.vision_model()}"'
989
+ except ToolError as e:
990
+ description, who = f"(lcode couldn't look at this image: {e})", ""
991
+ self.console.print(Text(f" ⎿ {e}", style="yellow"))
992
+ else:
993
+ self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
994
+ attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
995
+
996
+ def missing_image(shown: str) -> None:
997
+ self.console.print(
998
+ Text(f" ⎿ {shown} doesn't exist, so the model can't see it. Paste images with Ctrl+V.", style="yellow")
999
+ )
1000
+ attached.append(
1001
+ f"[lcode: the user's message mentions the image {shown}, but there's no such file, so it wasn't "
1002
+ "attached and you can't see it. Don't guess what it shows: ask the user to paste it again (Ctrl+V "
1003
+ "in lcode's prompt) or to describe it.]"
1004
+ )
1005
+
1006
+ for match in MENTION.finditer(text):
1007
+ ref = next(g for g in match.groups() if g)
971
1008
  p = self.tools.resolve(ref)
1009
+ seen.add(p)
972
1010
  name = ref.removeprefix("agent-")
973
1011
  if not p.exists() and self.settings.subagents and name in self.agent_types():
974
1012
  attached.append(f'[The user wants the {name} agent for this: call the agent tool with type "{name}".]')
975
1013
  continue
976
1014
  if p.is_file() and vision.is_image(p):
977
- try:
978
- description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
979
- who = f' described_by="{self.vision_model()}"'
980
- except ToolError as e:
981
- description, who = f"(lcode couldn't look at this image: {e})", ""
982
- self.console.print(Text(f" ⎿ {e}", style="yellow"))
983
- else:
984
- self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
985
- attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
1015
+ attach_image(p)
986
1016
  elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
987
1017
  attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
988
1018
  self.tools.read_mtimes[str(p)] = p.stat().st_mtime
989
1019
  self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
990
1020
  elif p.is_dir():
991
1021
  attached.append(f'<directory path="{self.tools.rel(p)}">\n{tree(p, 2)}\n</directory>')
1022
+ elif not p.exists() and vision.is_image(p):
1023
+ missing_image(ref)
1024
+ for match in IMAGE_PATH.finditer(text):
1025
+ quoted, raw = bool(match.group(2)), match.group(2) or match.group(3)
1026
+ if raw.startswith("@") or "://" in raw.removeprefix("file://"):
1027
+ continue # attached above, or a web address
1028
+ if raw.startswith("file://"):
1029
+ shown = urllib.parse.unquote(raw.removeprefix("file://"))
1030
+ else:
1031
+ shown = raw.replace("\\ ", " ")
1032
+ p = self.tools.resolve(shown)
1033
+ if p in seen:
1034
+ continue
1035
+ seen.add(p)
1036
+ if p.is_file():
1037
+ attach_image(p)
1038
+ elif quoted or raw.startswith(("/", "~", "./", "../", "file://")):
1039
+ missing_image(shown) # a name without a folder may be a file to create: no warning
992
1040
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
993
1041
 
994
1042
  # -- context management
lcode/cli.py CHANGED
@@ -346,6 +346,10 @@ def cmd_doctor(args) -> None:
346
346
  line("Vision", "off; set vision_model to a model the server serves that can see images", None)
347
347
  else:
348
348
  line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
349
+ if seer:
350
+ from lcode import clipboard
351
+
352
+ line("Clipboard", *clipboard.status())
349
353
  if limits.get(model):
350
354
  line(
351
355
  "Limit",
lcode/clipboard.py ADDED
@@ -0,0 +1,139 @@
1
+ """Paste from the clipboard with Ctrl+V, images included.
2
+
3
+ A terminal can only paste text, so a screenshot never reaches lcode that way. On Ctrl+V lcode reads the
4
+ clipboard itself: with wl-paste on Wayland, xclip on X11, and pngpaste or osascript on macOS. An image is
5
+ saved in lcode's state folder and goes into the prompt as `@path`, so it's attached like any other image;
6
+ anything else is pasted as text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from lcode import config
20
+
21
+ TIMEOUT = 5 # seconds for a clipboard tool
22
+ IMAGE_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
23
+ SUFFIXES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
24
+ KEEP = 50 # pasted images kept; older ones are removed
25
+
26
+
27
+ @dataclass
28
+ class Paste:
29
+ image: Path | None = None # a saved image
30
+ text: str = "" # or text
31
+ problem: str = "" # why nothing could be pasted
32
+
33
+
34
+ def run(argv: list[str]) -> bytes | None:
35
+ try:
36
+ done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
37
+ except (OSError, subprocess.SubprocessError):
38
+ return None
39
+ return done.stdout if done.returncode == 0 else None
40
+
41
+
42
+ def folder() -> Path:
43
+ return config.STATE_DIR / "pasted"
44
+
45
+
46
+ def save(data: bytes, suffix: str) -> Path:
47
+ where = folder()
48
+ where.mkdir(parents=True, exist_ok=True)
49
+ old = sorted(where.glob("pasted-*")) # the names sort by time
50
+ for path in old[: max(0, len(old) - KEEP + 1)]:
51
+ path.unlink(missing_ok=True)
52
+ now = time.time_ns()
53
+ stamp = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now / 1e9))}-{now % 1_000_000_000:09d}"
54
+ path = where / f"pasted-{stamp}{suffix}"
55
+ n = 1
56
+ while path.exists():
57
+ n += 1
58
+ path = where / f"pasted-{stamp}-{n}{suffix}"
59
+ path.write_bytes(data)
60
+ return path
61
+
62
+
63
+ def pick(types: list[str]) -> str | None:
64
+ return next((t for t in IMAGE_TYPES if t in types), None)
65
+
66
+
67
+ def paste() -> Paste:
68
+ """Whatever is on the clipboard: an image (saved to a file) or text."""
69
+ if sys.platform == "darwin":
70
+ return paste_mac()
71
+ if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-paste"):
72
+ return paste_with(
73
+ ["wl-paste", "--list-types"],
74
+ lambda kind: ["wl-paste", "--no-newline", "--type", kind],
75
+ ["wl-paste", "--no-newline"],
76
+ )
77
+ if os.environ.get("DISPLAY") and shutil.which("xclip"):
78
+ return paste_with(
79
+ ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
80
+ lambda kind: ["xclip", "-selection", "clipboard", "-t", kind, "-o"],
81
+ ["xclip", "-selection", "clipboard", "-o"],
82
+ )
83
+ return Paste(problem=status()[0])
84
+
85
+
86
+ def status() -> tuple[str, bool | None]:
87
+ """Whether Ctrl+V can paste images here, and if not, what to do (for the paste and `lcode doctor`)."""
88
+ if sys.platform == "darwin":
89
+ return "Ctrl+V pastes images" + (" (with pngpaste)" if shutil.which("pngpaste") else ""), True
90
+ if os.environ.get("WAYLAND_DISPLAY"):
91
+ if shutil.which("wl-paste"):
92
+ return "Ctrl+V pastes images (with wl-paste)", True
93
+ return "To paste images with Ctrl+V, install wl-clipboard (for example `sudo apt install wl-clipboard`).", None
94
+ if os.environ.get("DISPLAY"):
95
+ if shutil.which("xclip"):
96
+ return "Ctrl+V pastes images (with xclip)", True
97
+ return "To paste images with Ctrl+V, install xclip (for example `sudo apt install xclip`).", None
98
+ return "There's no clipboard here (no desktop session), so give an image by its path.", None
99
+
100
+
101
+ def paste_with(list_types: list[str], get, get_text: list[str]) -> Paste:
102
+ types = (run(list_types) or b"").decode(errors="replace").split()
103
+ kind = pick(types)
104
+ if kind:
105
+ data = run(get(kind))
106
+ if data:
107
+ return Paste(image=save(data, SUFFIXES[kind]))
108
+ text = run(get_text)
109
+ if text:
110
+ return Paste(text=text.decode(errors="replace"))
111
+ return Paste(problem="The clipboard is empty.")
112
+
113
+
114
+ def paste_mac() -> Paste:
115
+ if shutil.which("pngpaste"):
116
+ data = run(["pngpaste", "-"])
117
+ if data:
118
+ return Paste(image=save(data, ".png"))
119
+ else:
120
+ target = folder() / ".clipboard.png"
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ target.unlink(missing_ok=True)
123
+ script = [
124
+ "try",
125
+ "set png to (the clipboard as «class PNGf»)",
126
+ f'set f to open for access POSIX file "{target}" with write permission',
127
+ "write png to f",
128
+ "close access f",
129
+ "end try",
130
+ ]
131
+ run(["osascript", *[arg for line in script for arg in ("-e", line)]])
132
+ if target.exists() and target.stat().st_size:
133
+ data = target.read_bytes()
134
+ target.unlink(missing_ok=True)
135
+ return Paste(image=save(data, ".png"))
136
+ text = run(["pbpaste"])
137
+ if text:
138
+ return Paste(text=text.decode(errors="replace"))
139
+ return Paste(problem="The clipboard is empty.")
lcode/repl.py CHANGED
@@ -14,6 +14,7 @@ import time
14
14
  from pathlib import Path
15
15
 
16
16
  from prompt_toolkit import PromptSession
17
+ from prompt_toolkit.application import run_in_terminal
17
18
  from prompt_toolkit.completion import Completer, Completion
18
19
  from prompt_toolkit.formatted_text import HTML
19
20
  from prompt_toolkit.history import FileHistory
@@ -26,7 +27,7 @@ from rich.rule import Rule
26
27
  from rich.table import Table
27
28
  from rich.text import Text
28
29
 
29
- from lcode import __version__, backends, catalog, extensions, gitflow, limits, sessions, web
30
+ from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, sessions, web
30
31
  from lcode import context as context_tools
31
32
  from lcode import memory as memory_notes
32
33
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -101,6 +102,13 @@ class InputCompleter(Completer):
101
102
  yield Completion(completion, start_position=-len(fragment))
102
103
 
103
104
 
105
+ def mention(path: Path) -> str:
106
+ """How a file goes into the prompt: @path, with ~ for the home folder, in quotes if it has spaces."""
107
+ home = Path.home()
108
+ shown = f"~/{path.relative_to(home)}" if path.is_relative_to(home) else str(path)
109
+ return f'@"{shown}"' if " " in shown else f"@{shown}"
110
+
111
+
104
112
  def build_session(agent: Agent) -> PromptSession:
105
113
  kb = KeyBindings()
106
114
 
@@ -119,6 +127,19 @@ def build_session(agent: Agent) -> PromptSession:
119
127
  def _newline(event):
120
128
  event.current_buffer.insert_text("\n")
121
129
 
130
+ @kb.add("c-v")
131
+ def _paste(event):
132
+ """Paste the clipboard: an image becomes @path (terminals can't paste images), anything else is text."""
133
+ got = clipboard.paste()
134
+ buf = event.current_buffer
135
+ if got.image:
136
+ before = buf.document.char_before_cursor
137
+ buf.insert_text(("" if not before or before.isspace() else " ") + mention(got.image) + " ")
138
+ elif got.text:
139
+ buf.insert_text(got.text.replace("\r\n", "\n"))
140
+ else:
141
+ run_in_terminal(lambda: agent.console.print(Text(f" {got.problem}", style="yellow")))
142
+
122
143
  @kb.add("s-tab")
123
144
  def _cycle_mode(event):
124
145
  agent.perms.cycle()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.23.0
3
+ Version: 0.24.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/
@@ -1,15 +1,16 @@
1
- lcode/__init__.py,sha256=G36hAKq_BqNwKGr17AmtWXAKHe-_Nnbzm_VWmWBC-SI,473
1
+ lcode/__init__.py,sha256=TKS4ocYb1I6mIhEHcL41tMRS6veQZlQM9xS4vXe2mCk,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
4
4
  lcode/action.py,sha256=W-QqdGt4VAVLq7mcIdvL9UrEOwDEWvkZ26gNDv8k1kg,16330
5
- lcode/agent.py,sha256=Br-L-7sLnGBG_Hw2lwi19peFZldCh4Ve7PgtVPURjTU,53254
5
+ lcode/agent.py,sha256=fXy5sCexEhOjCcdmQJ-_gAYYX0JhNog5uXaGyptUiL4,55568
6
6
  lcode/api.py,sha256=FQ6aVLB936Cu2XEBmuOoq0u1DfOiExmgap0Y3WxJ2mQ,15376
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=ue5wsoFxraXXVu7X32S4tbKPLKYhgeI6zpK2i5nXXlU,40180
12
+ lcode/cli.py,sha256=cH5mcYi54Aofcv0PDNgFmFFQX8OQjhwTGdbWMUM_FG8,40289
13
+ lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
13
14
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
14
15
  lcode/config.py,sha256=fGjK4fM_YRY0qDQmbIVT3FAfL2I2UxLfYEFxnAI-Xu4,10365
15
16
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
@@ -29,7 +30,7 @@ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
29
30
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
30
31
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
31
32
  lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
32
- lcode/repl.py,sha256=pQAPePNsBxJfcuif2Cvyxr0FSeO_DVGv_T5cL9BrWu4,48470
33
+ lcode/repl.py,sha256=ZKd4l4wDGKHVaGz8bxjLGc2gRc2W-RpHPQYG9M2A_Zw,49441
33
34
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
34
35
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
35
36
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
@@ -54,8 +55,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
54
55
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
55
56
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
56
57
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
57
- lcode_cli-0.23.0.dist-info/METADATA,sha256=r2mvuIagBxzC4y5i14mZ7tYHnhy-Osy50M-wTYhkk-4,12620
58
- lcode_cli-0.23.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
59
- lcode_cli-0.23.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
60
- lcode_cli-0.23.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
61
- lcode_cli-0.23.0.dist-info/RECORD,,
58
+ lcode_cli-0.24.0.dist-info/METADATA,sha256=el8BWCJBrgargRIUs2z5O4a5ZKZyVQ9-6vCtuhmFf3k,12620
59
+ lcode_cli-0.24.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
60
+ lcode_cli-0.24.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
61
+ lcode_cli-0.24.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
62
+ lcode_cli-0.24.0.dist-info/RECORD,,