lcode-cli 0.28.0__py3-none-any.whl → 0.28.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +1 -1
- lcode/agent.py +31 -2
- lcode/api.py +1 -0
- lcode/context.py +68 -0
- lcode/permissions.py +153 -31
- lcode/repl.py +1 -0
- lcode/tools.py +160 -33
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.28.1.dist-info}/METADATA +1 -1
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.28.1.dist-info}/RECORD +12 -12
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.28.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.28.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.28.0.dist-info → lcode_cli-0.28.1.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import datetime as dt
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import platform
|
|
10
11
|
import re
|
|
11
12
|
import shutil
|
|
@@ -81,6 +82,8 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
|
|
|
81
82
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
82
83
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
83
84
|
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
85
|
+
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
86
|
+
MIN_RESULT_CHARS = 8000
|
|
84
87
|
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
85
88
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
86
89
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
@@ -320,7 +323,9 @@ class Agent:
|
|
|
320
323
|
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
321
324
|
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
322
325
|
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
326
|
+
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
323
327
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
328
|
+
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
324
329
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
325
330
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
326
331
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -576,6 +581,7 @@ class Agent:
|
|
|
576
581
|
self.tools.read_mtimes.clear()
|
|
577
582
|
self.reflected = len(self.messages)
|
|
578
583
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
584
|
+
self.counted_chars = None
|
|
579
585
|
|
|
580
586
|
def session_file(self) -> Path:
|
|
581
587
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -597,7 +603,10 @@ class Agent:
|
|
|
597
603
|
"messages": self.messages,
|
|
598
604
|
"checkpoints": self.checkpoints.to_json(),
|
|
599
605
|
}
|
|
600
|
-
|
|
606
|
+
# Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
|
|
607
|
+
partial = f.with_name(f".{f.name}.partial")
|
|
608
|
+
partial.write_text(json.dumps(data))
|
|
609
|
+
os.replace(partial, f)
|
|
601
610
|
|
|
602
611
|
def new_session(self) -> None:
|
|
603
612
|
self.reset()
|
|
@@ -634,6 +643,7 @@ class Agent:
|
|
|
634
643
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
635
644
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
636
645
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
646
|
+
self.counted_chars = None
|
|
637
647
|
return note
|
|
638
648
|
|
|
639
649
|
def load_latest(self) -> bool:
|
|
@@ -797,7 +807,7 @@ class Agent:
|
|
|
797
807
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
798
808
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
799
809
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
-
self.sent_chars = conversation_chars(self.messages)
|
|
810
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
801
811
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
802
812
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
803
813
|
return message
|
|
@@ -943,6 +953,8 @@ class Agent:
|
|
|
943
953
|
for step in range(self.max_steps):
|
|
944
954
|
if self.cancel is not None and self.cancel.is_set():
|
|
945
955
|
raise KeyboardInterrupt
|
|
956
|
+
if step and self.autosave:
|
|
957
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
946
958
|
self.maybe_compact()
|
|
947
959
|
try:
|
|
948
960
|
reply = self.assistant_step()
|
|
@@ -1027,6 +1039,9 @@ class Agent:
|
|
|
1027
1039
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
1028
1040
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
1029
1041
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
1042
|
+
budget = self.result_budget()
|
|
1043
|
+
if len(result) > budget and name not in ("agent", "skill"):
|
|
1044
|
+
result = context_tools.shorten(result, budget, f"{name} result")
|
|
1030
1045
|
verdict = self.repeats.check(name, args, result)
|
|
1031
1046
|
if verdict.note:
|
|
1032
1047
|
result += verdict.note
|
|
@@ -1161,8 +1176,21 @@ class Agent:
|
|
|
1161
1176
|
limit = min(limit, self.settings.compact_at)
|
|
1162
1177
|
return limit
|
|
1163
1178
|
|
|
1179
|
+
def next_request_tokens(self) -> int:
|
|
1180
|
+
"""The size of the next request: what the model last counted, plus what was added since (tool output,
|
|
1181
|
+
attachments). Deciding on the last count alone let one large tool result overflow the window."""
|
|
1182
|
+
if self.counted_chars is None:
|
|
1183
|
+
return self.ctx_used
|
|
1184
|
+
return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
|
|
1185
|
+
|
|
1186
|
+
def result_budget(self) -> int:
|
|
1187
|
+
"""Characters one tool result may take: a share of the window, so one large result can't fill it."""
|
|
1188
|
+
return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
|
|
1189
|
+
|
|
1164
1190
|
def maybe_compact(self) -> None:
|
|
1165
1191
|
limit = self.compact_limit()
|
|
1192
|
+
if self.next_request_tokens() > limit:
|
|
1193
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1166
1194
|
if self.ctx_used <= limit:
|
|
1167
1195
|
return
|
|
1168
1196
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1231,6 +1259,7 @@ class Agent:
|
|
|
1231
1259
|
)
|
|
1232
1260
|
self.skills_loaded.clear()
|
|
1233
1261
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1262
|
+
self.counted_chars = None
|
|
1234
1263
|
self.summarized_size = self.ctx_used
|
|
1235
1264
|
self.sent_chars = 0
|
|
1236
1265
|
self.reflected = len(self.messages)
|
lcode/api.py
CHANGED
|
@@ -200,6 +200,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
200
|
)
|
|
201
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
202
|
agent.hardware = hw
|
|
203
|
+
agent.autosave = True
|
|
203
204
|
agent.interactive = options.interactive
|
|
204
205
|
if options.max_steps:
|
|
205
206
|
agent.max_steps = options.max_steps
|
lcode/context.py
CHANGED
|
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
17
18
|
import re
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import TYPE_CHECKING
|
|
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
|
|
|
253
255
|
return f"{head}{note}{tail}{where}"
|
|
254
256
|
|
|
255
257
|
|
|
258
|
+
class OutputSpool:
|
|
259
|
+
"""Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
|
|
260
|
+
start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, limit: int, label: str = "output") -> None:
|
|
263
|
+
self.limit, self.label = limit, label
|
|
264
|
+
self.head: list[str] = [] # everything, until the output gets long; then its start
|
|
265
|
+
self.tail: deque[str] = deque()
|
|
266
|
+
self.tail_chars = 0
|
|
267
|
+
self.errors: list[str] = []
|
|
268
|
+
self.chars = 0
|
|
269
|
+
self.file = None
|
|
270
|
+
self.path: Path | None = None
|
|
271
|
+
|
|
272
|
+
def add(self, line: str) -> None:
|
|
273
|
+
self.chars += len(line)
|
|
274
|
+
if self.file is None:
|
|
275
|
+
self.head.append(line)
|
|
276
|
+
if self.chars > self.limit * 2:
|
|
277
|
+
self._spool()
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
self.file.write(line)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
|
|
284
|
+
self.errors.append(line.strip()[:300])
|
|
285
|
+
self.tail.append(line)
|
|
286
|
+
self.tail_chars += len(line)
|
|
287
|
+
while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
|
|
288
|
+
self.tail_chars -= len(self.tail.popleft())
|
|
289
|
+
|
|
290
|
+
def _spool(self) -> None:
|
|
291
|
+
text = "".join(self.head)
|
|
292
|
+
self.path = save_output(text, self.label)
|
|
293
|
+
if self.path is not None:
|
|
294
|
+
try:
|
|
295
|
+
self.file = self.path.open("a")
|
|
296
|
+
except OSError:
|
|
297
|
+
self.file = None
|
|
298
|
+
if self.file is None: # nowhere to keep it: keep only the start and carry on counting
|
|
299
|
+
self.file = open(os.devnull, "w") # noqa: SIM115
|
|
300
|
+
head = text[: self.limit * 3 // 5]
|
|
301
|
+
self.head = [head]
|
|
302
|
+
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
if self.file is None:
|
|
305
|
+
return shorten("".join(self.head), self.limit, self.label)
|
|
306
|
+
self.file.close()
|
|
307
|
+
head, tail = "".join(self.head), "".join(self.tail)
|
|
308
|
+
cut = self.chars - len(head) - len(tail)
|
|
309
|
+
note = f"\n\n... [{cut} characters cut"
|
|
310
|
+
if self.errors:
|
|
311
|
+
note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
|
|
312
|
+
note += "\n[...]\n\n"
|
|
313
|
+
else:
|
|
314
|
+
note += "] ...\n\n"
|
|
315
|
+
where = (
|
|
316
|
+
f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
|
|
317
|
+
"if you need them.]"
|
|
318
|
+
if self.path
|
|
319
|
+
else ""
|
|
320
|
+
)
|
|
321
|
+
return f"{head}{note}{tail}{where}"
|
|
322
|
+
|
|
323
|
+
|
|
256
324
|
# ----------------------------------------------------------------------------- what uses the context
|
|
257
325
|
|
|
258
326
|
|
lcode/permissions.py
CHANGED
|
@@ -22,51 +22,173 @@ SAFE_GIT = {"status", "log", "diff", "show", "branch", "ls-files", "rev-parse",
|
|
|
22
22
|
MULTI_WORD = {"git", "npm", "pnpm", "yarn", "pip", "uv", "docker", "cargo", "go", "kubectl", "python", "python3", "brew"} # fmt: skip # noqa: E501
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
# `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
|
|
26
|
+
NOT_ASKED = (
|
|
27
|
+
"This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
|
|
28
|
+
"if you can; otherwise finish and say what permission you need."
|
|
29
|
+
)
|
|
30
|
+
LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Redirections that write nothing anyone keeps: stderr into stdout, or output thrown away.
|
|
34
|
+
HARMLESS_REDIRECT = re.compile(r"(?<![\w>&])(?:[12]?>&[12]|[12]?>\s*/dev/null)(?=\s|$)")
|
|
35
|
+
OPERATORS = {"|", "||", "&&", ";", "&", ">", ">>", "<", "<<", "<<<", ">&", "<&", ">|", "(", ")", ";;"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_command(command: str) -> tuple[list[list[str]], list[str]] | None:
|
|
39
|
+
"""The command's segments (as words) and the operators between them, quotes respected. None when it can't be
|
|
40
|
+
read: command substitution, process substitution or unbalanced quotes."""
|
|
41
|
+
if any(tok in command for tok in ("`", "$(", "<(", ">(")) or "\n" in command.strip():
|
|
42
|
+
return None
|
|
43
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
44
|
+
lexer.whitespace_split = True
|
|
28
45
|
try:
|
|
29
|
-
|
|
46
|
+
tokens = list(lexer)
|
|
30
47
|
except ValueError:
|
|
31
|
-
|
|
48
|
+
return None
|
|
49
|
+
segments: list[list[str]] = [[]]
|
|
50
|
+
operators: list[str] = []
|
|
51
|
+
for token in tokens:
|
|
52
|
+
if token in OPERATORS or (token and set(token) <= set("|&;<>()")):
|
|
53
|
+
operators.append(token)
|
|
54
|
+
segments.append([])
|
|
55
|
+
else:
|
|
56
|
+
segments[-1].append(token)
|
|
57
|
+
return [s for s in segments if s], operators
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def segment_key(words: list[str]) -> str:
|
|
61
|
+
"""The program, plus the subcommand for tools like git (`git commit`, `python -m pytest`)."""
|
|
62
|
+
while words and re.fullmatch(r"\w+=\S*", words[0]):
|
|
63
|
+
words = words[1:] # VAR=value in front
|
|
32
64
|
if not words:
|
|
33
|
-
return "
|
|
65
|
+
return ""
|
|
34
66
|
if words[0] in MULTI_WORD and len(words) > 1:
|
|
35
67
|
if words[1] in ("-m", "-c") and len(words) > 2: # python -m <module>
|
|
36
|
-
return
|
|
37
|
-
return f"
|
|
38
|
-
return
|
|
68
|
+
return " ".join(words[:3])
|
|
69
|
+
return f"{words[0]} {words[1]}"
|
|
70
|
+
return words[0]
|
|
39
71
|
|
|
40
72
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
46
|
-
|
|
73
|
+
def bash_key(command: str) -> str:
|
|
74
|
+
"""What "always allow" remembers. For one program, the program (plus subcommand for git and the like); for a
|
|
75
|
+
chain such as `npm test && rm -rf build`, every program in it, so allowing `npm test` doesn't allow the chain.
|
|
76
|
+
`cd dir &&` in front doesn't count."""
|
|
77
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
78
|
+
parsed = split_command(command)
|
|
79
|
+
if parsed is None:
|
|
80
|
+
return "bash:" + " ".join(command.split()) # can't be read: only this exact command
|
|
81
|
+
segments, operators = parsed
|
|
82
|
+
keys, redirected = [], False
|
|
83
|
+
for i, words in enumerate(segments):
|
|
84
|
+
before = operators[i - 1] if i else ""
|
|
85
|
+
if before and before not in ("|", "&&", "||", ";", "&"):
|
|
86
|
+
redirected = True # a file name after > or <, not a program
|
|
87
|
+
continue
|
|
88
|
+
keys.append(segment_key(words))
|
|
89
|
+
key = " + ".join(k for k in keys if k) + (" (redirected)" if redirected else "")
|
|
90
|
+
return f"bash:{key}"
|
|
47
91
|
|
|
48
92
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
def option_values(words: list[str], *options: str) -> list[str]:
|
|
94
|
+
"""The values of options such as `-o FILE`, `-oFILE` or `--output=FILE`."""
|
|
95
|
+
found = []
|
|
96
|
+
for i, word in enumerate(words):
|
|
97
|
+
for option in options:
|
|
98
|
+
if word == option and i + 1 < len(words):
|
|
99
|
+
found.append(words[i + 1])
|
|
100
|
+
elif option.startswith("--") and word.startswith(option + "="):
|
|
101
|
+
found.append(word.split("=", 1)[1])
|
|
102
|
+
elif not option.startswith("--") and word.startswith(option) and len(word) > len(option):
|
|
103
|
+
found.append(word[len(option) :])
|
|
104
|
+
return found
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def flags(words: list[str]) -> list[str]:
|
|
108
|
+
return [w for w in words[1:] if w.startswith("-")]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def positional(words: list[str]) -> list[str]:
|
|
112
|
+
return [w for w in words[1:] if not w.startswith("-")]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Branch and remote listing; anything else creates, deletes, renames or reconfigures.
|
|
116
|
+
BRANCH_LISTING = {
|
|
117
|
+
"-a", "--all", "-r", "--remotes", "-l", "--list", "-v", "-vv", "--verbose", "--show-current", "--color",
|
|
118
|
+
"--no-color", "--column", "--no-column", "--abbrev", "--no-abbrev", "-i", "--ignore-case", "--omit-empty",
|
|
119
|
+
} # fmt: skip
|
|
120
|
+
BRANCH_WITH_VALUE = {"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}
|
|
121
|
+
NVIDIA_QUERY = ("-q", "--query", "-L", "--list-gpus", "--format", "-i", "--id", "-d", "--display", "-x", "--xml-format",
|
|
122
|
+
"-h", "--help", "--query-gpu", "--query-compute-apps") # fmt: skip
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def safe_git(words: list[str]) -> bool:
|
|
126
|
+
if len(words) < 2 or words[1] not in SAFE_GIT:
|
|
127
|
+
return False # also `git -c …` and `git -C …`, which can run anything
|
|
128
|
+
sub, args = words[1], words[2:]
|
|
129
|
+
if sub in ("log", "show", "diff", "shortlog") and option_values(words, "--output"):
|
|
130
|
+
return False # writes the output to a file
|
|
131
|
+
if sub == "branch":
|
|
132
|
+
listing = any(a in ("-l", "--list") for a in args)
|
|
133
|
+
i = 0
|
|
134
|
+
while i < len(args):
|
|
135
|
+
arg = args[i]
|
|
136
|
+
name = arg.split("=", 1)[0]
|
|
137
|
+
if name in BRANCH_WITH_VALUE:
|
|
138
|
+
i += 1 if "=" in arg else 2
|
|
139
|
+
continue
|
|
140
|
+
if arg.startswith("-") and name not in BRANCH_LISTING:
|
|
141
|
+
return False # -d, -D, -m, -c, -f, -u, --set-upstream-to, --edit-description, …
|
|
142
|
+
if not arg.startswith("-") and not listing:
|
|
143
|
+
return False # `git branch name` creates a branch
|
|
144
|
+
i += 1
|
|
145
|
+
if sub == "remote":
|
|
146
|
+
return not args or all(a in ("-v", "--verbose") for a in args) or args[0] in ("show", "get-url")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def safe_segment(words: list[str]) -> bool:
|
|
151
|
+
program = words[0]
|
|
152
|
+
if program == "git":
|
|
153
|
+
return safe_git(words)
|
|
154
|
+
if program not in SAFE_COMMANDS:
|
|
52
155
|
return False
|
|
53
|
-
if
|
|
156
|
+
if program == "sort" and (
|
|
157
|
+
option_values(words, "-o", "--output") or any(f.startswith("--compress-program") for f in flags(words))
|
|
158
|
+
):
|
|
54
159
|
return False
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
160
|
+
if program == "uniq" and len(positional(words)) > 1:
|
|
161
|
+
return False # `uniq in out` writes out
|
|
162
|
+
if program == "tree" and option_values(words, "-o"):
|
|
163
|
+
return False
|
|
164
|
+
if program == "rg" and any(f.split("=")[0] in ("--pre", "--pre-glob") for f in flags(words)):
|
|
165
|
+
return False # runs a program on each file
|
|
166
|
+
if program == "find" and any(
|
|
167
|
+
w in ("-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls") for w in words
|
|
168
|
+
):
|
|
169
|
+
return False
|
|
170
|
+
if program == "date" and (option_values(words, "-s", "--set") or "-s" in words):
|
|
171
|
+
return False
|
|
172
|
+
if program == "file" and any(f in ("-C", "--compile") for f in flags(words)):
|
|
173
|
+
return False
|
|
174
|
+
if program == "nvidia-smi":
|
|
175
|
+
return all(f.split("=")[0] in NVIDIA_QUERY for f in flags(words))
|
|
67
176
|
return True
|
|
68
177
|
|
|
69
178
|
|
|
179
|
+
def is_read_only(command: str) -> bool:
|
|
180
|
+
"""Whether a command only reads: known read-only programs with arguments that don't write, joined only by
|
|
181
|
+
pipes. `cd dir &&` in front, `2>&1` and `>/dev/null` are fine."""
|
|
182
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
183
|
+
parsed = split_command(command)
|
|
184
|
+
if parsed is None:
|
|
185
|
+
return False
|
|
186
|
+
segments, operators = parsed
|
|
187
|
+
if not segments or any(op != "|" for op in operators):
|
|
188
|
+
return False
|
|
189
|
+
return all(safe_segment(words) for words in segments)
|
|
190
|
+
|
|
191
|
+
|
|
70
192
|
class Permissions:
|
|
71
193
|
def __init__(self, console: Console, mode: str = "ask"):
|
|
72
194
|
from lcode.hooks import Rules
|
lcode/repl.py
CHANGED
|
@@ -754,6 +754,7 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
754
754
|
agent.messages = agent.messages[:index]
|
|
755
755
|
agent.reflected = min(agent.reflected, index)
|
|
756
756
|
agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
|
|
757
|
+
agent.counted_chars = None
|
|
757
758
|
c.print(f"The conversation is back to before request {target.n} as well.")
|
|
758
759
|
elif plan.restore or plan.remove:
|
|
759
760
|
what = "undid the file changes from" if not rewind else "rolled the files back to before"
|
lcode/tools.py
CHANGED
|
@@ -6,6 +6,7 @@ import difflib
|
|
|
6
6
|
import fnmatch
|
|
7
7
|
import hashlib
|
|
8
8
|
import inspect
|
|
9
|
+
import itertools
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import queue
|
|
@@ -16,7 +17,9 @@ import signal
|
|
|
16
17
|
import subprocess
|
|
17
18
|
import threading
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
20
23
|
from pathlib import Path
|
|
21
24
|
from typing import TYPE_CHECKING
|
|
22
25
|
|
|
@@ -26,7 +29,7 @@ from rich.syntax import Syntax
|
|
|
26
29
|
from rich.text import Text
|
|
27
30
|
|
|
28
31
|
from lcode import secretscan, web
|
|
29
|
-
from lcode.context import shorten
|
|
32
|
+
from lcode.context import OutputSpool, shorten
|
|
30
33
|
from lcode.jobs import JobError, describe, stop_leftovers
|
|
31
34
|
from lcode.permissions import bash_key, is_read_only
|
|
32
35
|
from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
|
|
@@ -41,6 +44,7 @@ NO_NETWORK = re.compile(
|
|
|
41
44
|
r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
|
|
42
45
|
)
|
|
43
46
|
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
47
|
+
PENDING_LINES = 5000 # output lines waiting to be read, at most
|
|
44
48
|
LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running
|
|
45
49
|
IGNORE_DIRS = {
|
|
46
50
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
@@ -289,6 +293,48 @@ def truncate(text: str, limit: int = MAX_TOOL_OUTPUT) -> str:
|
|
|
289
293
|
return f"{head}\n\n... [{len(text) - len(head) - len(tail)} characters truncated] ...\n\n{tail}"
|
|
290
294
|
|
|
291
295
|
|
|
296
|
+
@dataclass
|
|
297
|
+
class TextFormat:
|
|
298
|
+
"""How a file stores its text, so an edit writes it back the same way."""
|
|
299
|
+
|
|
300
|
+
encoding: str = "utf-8"
|
|
301
|
+
crlf: bool = False # Windows line endings
|
|
302
|
+
bom: bool = False # a UTF-8 byte order mark
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def decode(raw: bytes) -> tuple[str, TextFormat]:
|
|
306
|
+
"""A file's text with \n line endings, and its format: UTF-8, else Windows-1252 or Latin-1 (which always
|
|
307
|
+
decode), never with characters replaced. Writing a replaced character back used to corrupt the whole file."""
|
|
308
|
+
fmt = TextFormat(bom=raw.startswith(b"\xef\xbb\xbf"))
|
|
309
|
+
text = ""
|
|
310
|
+
for encoding in ("utf-8-sig" if fmt.bom else "utf-8", "cp1252", "latin-1"):
|
|
311
|
+
try:
|
|
312
|
+
text = raw.decode(encoding)
|
|
313
|
+
fmt.encoding = "utf-8" if encoding == "utf-8-sig" else encoding
|
|
314
|
+
break
|
|
315
|
+
except UnicodeDecodeError:
|
|
316
|
+
continue
|
|
317
|
+
crlf = text.count("\r\n")
|
|
318
|
+
if crlf and crlf == text.count("\n"): # only CRLF: mixed files are left as they are
|
|
319
|
+
fmt.crlf = True
|
|
320
|
+
text = text.replace("\r\n", "\n")
|
|
321
|
+
return text, fmt
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def encode(text: str, fmt: TextFormat) -> bytes:
|
|
325
|
+
if fmt.crlf:
|
|
326
|
+
text = text.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
327
|
+
try:
|
|
328
|
+
data = text.encode(fmt.encoding)
|
|
329
|
+
except UnicodeEncodeError as e:
|
|
330
|
+
bad = text[e.start : e.end]
|
|
331
|
+
raise ToolError(
|
|
332
|
+
f"the file is in {fmt.encoding}, which can't hold {bad!r}: write it without that character, or ask the "
|
|
333
|
+
"user to convert the file to UTF-8"
|
|
334
|
+
) from e
|
|
335
|
+
return (b"\xef\xbb\xbf" if fmt.bom else b"") + data
|
|
336
|
+
|
|
337
|
+
|
|
292
338
|
def is_binary(path: Path) -> bool:
|
|
293
339
|
try:
|
|
294
340
|
with open(path, "rb") as f:
|
|
@@ -362,15 +408,28 @@ def parse_text_tool_calls(content: str) -> list[dict]:
|
|
|
362
408
|
pass
|
|
363
409
|
if calls:
|
|
364
410
|
return calls
|
|
365
|
-
#
|
|
411
|
+
# A message that is nothing but JSON calls such as {"name": "grep", "arguments": {...}}, possibly in a ```
|
|
412
|
+
# fence. JSON inside an answer is an example, not a call: running it once ran a command the model was only
|
|
413
|
+
# explaining.
|
|
414
|
+
body = content.strip()
|
|
415
|
+
lead = re.match(r"([^\n{`]{0,50})\n+(?=```|\{)", body) # a short lead-in such as "Sure:" is fine
|
|
416
|
+
if lead:
|
|
417
|
+
body = body[lead.end() :]
|
|
418
|
+
body = re.sub(r"^```(?:json)?\s*|\s*```$", "", body)
|
|
366
419
|
decoder = json.JSONDecoder()
|
|
367
|
-
|
|
420
|
+
position = 0
|
|
421
|
+
while position < len(body):
|
|
422
|
+
while position < len(body) and body[position].isspace():
|
|
423
|
+
position += 1
|
|
424
|
+
if position == len(body):
|
|
425
|
+
break
|
|
368
426
|
try:
|
|
369
|
-
obj,
|
|
427
|
+
obj, position = decoder.raw_decode(body, position)
|
|
370
428
|
except json.JSONDecodeError:
|
|
371
|
-
|
|
372
|
-
if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
|
|
373
|
-
|
|
429
|
+
return [] # something else in the message: not a call
|
|
430
|
+
if not (isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict)):
|
|
431
|
+
return []
|
|
432
|
+
calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
|
|
374
433
|
return calls
|
|
375
434
|
|
|
376
435
|
|
|
@@ -385,6 +444,7 @@ class Toolbox:
|
|
|
385
444
|
def __init__(self, agent: Agent):
|
|
386
445
|
self.agent = agent
|
|
387
446
|
self.read_mtimes: dict[str, float] = {}
|
|
447
|
+
self.formats: dict[str, TextFormat] = {} # encoding and line endings of the files read
|
|
388
448
|
self.todos: list[dict] = []
|
|
389
449
|
# An editor's buffers (lcode acp): read unsaved changes and write through the editor.
|
|
390
450
|
self.reader: Callable[[Path], str | None] | None = None
|
|
@@ -476,7 +536,7 @@ class Toolbox:
|
|
|
476
536
|
if problem:
|
|
477
537
|
return f"Error: bad arguments for {name}: {problem}"
|
|
478
538
|
try:
|
|
479
|
-
return fn(**args)
|
|
539
|
+
return fn(**self._typed(fn, args))
|
|
480
540
|
except ToolError as e:
|
|
481
541
|
return f"Error: {e}"
|
|
482
542
|
except TypeError as e:
|
|
@@ -484,6 +544,23 @@ class Toolbox:
|
|
|
484
544
|
except Exception as e: # surface every failure to the model instead of crashing the session
|
|
485
545
|
return f"Error: {type(e).__name__}: {e}"
|
|
486
546
|
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _typed(fn, args: dict) -> dict:
|
|
549
|
+
"""Arguments with text turned into the declared type: some servers and the text fallback send every value
|
|
550
|
+
as a string, and "false" is true in Python."""
|
|
551
|
+
params = inspect.signature(fn).parameters
|
|
552
|
+
typed = dict(args)
|
|
553
|
+
for name, value in args.items():
|
|
554
|
+
if not isinstance(value, str) or name not in params:
|
|
555
|
+
continue
|
|
556
|
+
kind = str(params[name].annotation).split("|")[0].strip()
|
|
557
|
+
text = value.strip().lower()
|
|
558
|
+
if kind == "bool" and text in ("true", "false", "1", "0", "yes", "no"):
|
|
559
|
+
typed[name] = text in ("true", "1", "yes")
|
|
560
|
+
elif kind == "int" and re.fullmatch(r"-?\d+", text):
|
|
561
|
+
typed[name] = int(text)
|
|
562
|
+
return typed
|
|
563
|
+
|
|
487
564
|
@staticmethod
|
|
488
565
|
def _argument_problem(fn, args: dict) -> str:
|
|
489
566
|
"""Explain unknown or missing arguments in terms the model can act on."""
|
|
@@ -639,9 +716,21 @@ class Toolbox:
|
|
|
639
716
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
640
717
|
if not lines:
|
|
641
718
|
return "(empty file)"
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
719
|
+
budget, shown, size = self.agent.result_budget() - 300, [], 0
|
|
720
|
+
for i, line in enumerate(chunk, start=offset):
|
|
721
|
+
numbered = f"{i:6}\t{line[:2000]}"
|
|
722
|
+
if shown and size + len(numbered) + 1 > budget:
|
|
723
|
+
break # the rest wouldn't fit in the context at once
|
|
724
|
+
shown.append(numbered)
|
|
725
|
+
size += len(numbered) + 1
|
|
726
|
+
out = "\n".join(shown)
|
|
727
|
+
end = offset - 1 + len(shown)
|
|
728
|
+
if end < offset - 1 + len(chunk):
|
|
729
|
+
out += (
|
|
730
|
+
f"\n\n[Showing lines {offset}-{end} of {len(lines)}: more doesn't fit in the context at once. "
|
|
731
|
+
f"Read on with offset={end + 1}.]"
|
|
732
|
+
)
|
|
733
|
+
elif end < len(lines) or offset > 1:
|
|
645
734
|
out += f"\n\n[Showing lines {offset}-{end} of {len(lines)}. Use offset/limit to read more.]"
|
|
646
735
|
return truncate(out, 120_000)
|
|
647
736
|
|
|
@@ -732,14 +821,17 @@ class Toolbox:
|
|
|
732
821
|
text = self.reader(p)
|
|
733
822
|
if text is not None:
|
|
734
823
|
return text
|
|
735
|
-
|
|
824
|
+
text, self.formats[str(p)] = decode(p.read_bytes())
|
|
825
|
+
return text
|
|
736
826
|
|
|
737
827
|
def _write(self, p: Path, content: str) -> None:
|
|
738
828
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
829
|
+
fmt = self.formats.get(str(p)) or (decode(p.read_bytes())[1] if p.exists() else TextFormat())
|
|
830
|
+
data = encode(content, fmt) # before anything is written: it may not be possible
|
|
739
831
|
if self.writer is not None:
|
|
740
832
|
self.writer(p, content) # the editor updates its buffer (and the file)
|
|
741
|
-
if not p.exists() or p.
|
|
742
|
-
p.
|
|
833
|
+
if not p.exists() or p.read_bytes() != data:
|
|
834
|
+
p.write_bytes(data) # the file on disk must match, for checkpoints and the tools
|
|
743
835
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
744
836
|
|
|
745
837
|
def t_write_file(self, path: str, content: str) -> str:
|
|
@@ -749,8 +841,10 @@ class Toolbox:
|
|
|
749
841
|
if p.is_dir():
|
|
750
842
|
raise ToolError(f"{path} is a directory")
|
|
751
843
|
self._check_fresh(p)
|
|
752
|
-
|
|
844
|
+
shown = self.read_text(p)
|
|
845
|
+
body = self._diff(p, shown, content)
|
|
753
846
|
else:
|
|
847
|
+
shown = None
|
|
754
848
|
lines = content.splitlines()
|
|
755
849
|
preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
|
|
756
850
|
body = Syntax(preview, Syntax.guess_lexer(str(p), content), theme="monokai", line_numbers=True)
|
|
@@ -758,13 +852,27 @@ class Toolbox:
|
|
|
758
852
|
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
|
|
759
853
|
if not ok:
|
|
760
854
|
return feedback
|
|
761
|
-
before = self.read_text(p) if exists else None
|
|
855
|
+
before = self.read_text(p) if p.exists() else None
|
|
856
|
+
if before != shown:
|
|
857
|
+
return self._changed_meanwhile(p)
|
|
762
858
|
self.agent.checkpoint()
|
|
763
859
|
self._write(p, content)
|
|
764
860
|
n = len(content.splitlines())
|
|
765
861
|
self.console.print(f" [green]✓[/] {'Updated' if exists else 'Created'} {self.rel(p)} ({n} lines)")
|
|
766
862
|
return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)." + self._check(p, before, content)
|
|
767
863
|
|
|
864
|
+
def _changed_meanwhile(self, p: Path) -> str:
|
|
865
|
+
"""The file changed while the user was asked (they edited it): writing now would undo their change."""
|
|
866
|
+
self.read_mtimes.pop(str(p), None)
|
|
867
|
+
self.console.print(
|
|
868
|
+
Text(f" ⎿ {self.rel(p)} changed while you were asked; the edit wasn't applied", style="yellow")
|
|
869
|
+
)
|
|
870
|
+
return (
|
|
871
|
+
f"Error: {self.rel(p)} changed while the user was being asked (they or another program edited it), so "
|
|
872
|
+
"lcode didn't apply this change: it would have undone theirs. Read the file again and redo the change "
|
|
873
|
+
"on its current content."
|
|
874
|
+
)
|
|
875
|
+
|
|
768
876
|
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
769
877
|
p = self.path(path)
|
|
770
878
|
if not p.exists():
|
|
@@ -795,6 +903,8 @@ class Toolbox:
|
|
|
795
903
|
ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", diff, self.project_rel(p))
|
|
796
904
|
if not ok:
|
|
797
905
|
return feedback
|
|
906
|
+
if not p.exists() or self.read_text(p) != text:
|
|
907
|
+
return self._changed_meanwhile(p)
|
|
798
908
|
self.agent.checkpoint()
|
|
799
909
|
self._write(p, new_text)
|
|
800
910
|
self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
|
|
@@ -879,13 +989,22 @@ class Toolbox:
|
|
|
879
989
|
start_new_session=True,
|
|
880
990
|
bufsize=1,
|
|
881
991
|
)
|
|
882
|
-
|
|
992
|
+
# Bounded, so a command that prints faster than lcode reads waits instead of filling memory.
|
|
993
|
+
lines: queue.Queue[str | None] = queue.Queue(maxsize=PENDING_LINES)
|
|
994
|
+
reading = threading.Event() # cleared when lcode stops reading
|
|
995
|
+
reading.set()
|
|
883
996
|
|
|
884
997
|
def pump() -> None:
|
|
885
998
|
assert proc.stdout is not None
|
|
886
|
-
for line in proc.stdout:
|
|
887
|
-
|
|
888
|
-
|
|
999
|
+
for line in itertools.chain(proc.stdout, [None]): # streamed: never the whole output
|
|
1000
|
+
while reading.is_set():
|
|
1001
|
+
try:
|
|
1002
|
+
lines.put(line, timeout=0.5)
|
|
1003
|
+
break
|
|
1004
|
+
except queue.Full:
|
|
1005
|
+
continue
|
|
1006
|
+
else:
|
|
1007
|
+
return
|
|
889
1008
|
|
|
890
1009
|
def stop() -> None:
|
|
891
1010
|
if sandbox and token:
|
|
@@ -893,7 +1012,9 @@ class Toolbox:
|
|
|
893
1012
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
894
1013
|
|
|
895
1014
|
threading.Thread(target=pump, daemon=True).start()
|
|
896
|
-
out
|
|
1015
|
+
out = OutputSpool(MAX_TOOL_OUTPUT, "output")
|
|
1016
|
+
last = "" # the latest line, for the end-of-output checks below
|
|
1017
|
+
recent: deque[str] = deque(maxlen=40)
|
|
897
1018
|
shown, status, recorded = 0, "", ""
|
|
898
1019
|
deadline = time.time() + int(timeout or 180)
|
|
899
1020
|
finished = 0.0 # when the command itself ended (its marker arrived)
|
|
@@ -902,16 +1023,16 @@ class Toolbox:
|
|
|
902
1023
|
while True:
|
|
903
1024
|
if self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
904
1025
|
raise KeyboardInterrupt
|
|
1026
|
+
if finished and time.time() - finished > LEFTOVER_GRACE:
|
|
1027
|
+
held_open = True # also when what it left running keeps printing
|
|
1028
|
+
break
|
|
1029
|
+
if time.time() > deadline and not finished: # also while it keeps printing
|
|
1030
|
+
stop()
|
|
1031
|
+
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
1032
|
+
break
|
|
905
1033
|
try:
|
|
906
1034
|
line = lines.get(timeout=0.2)
|
|
907
1035
|
except queue.Empty:
|
|
908
|
-
if finished and time.time() - finished > LEFTOVER_GRACE:
|
|
909
|
-
held_open = True
|
|
910
|
-
break
|
|
911
|
-
if time.time() > deadline:
|
|
912
|
-
stop()
|
|
913
|
-
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
914
|
-
break
|
|
915
1036
|
continue
|
|
916
1037
|
if line is None:
|
|
917
1038
|
break
|
|
@@ -919,14 +1040,19 @@ class Toolbox:
|
|
|
919
1040
|
recorded = line[len(marker) :].strip()
|
|
920
1041
|
finished = time.time()
|
|
921
1042
|
continue
|
|
922
|
-
|
|
1043
|
+
if last:
|
|
1044
|
+
out.add(last)
|
|
1045
|
+
recent.append(last)
|
|
1046
|
+
last = line
|
|
923
1047
|
if shown <= 40:
|
|
924
1048
|
msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
|
|
925
1049
|
self.console.print(Text(msg, style="dim"))
|
|
926
1050
|
shown += 1
|
|
927
1051
|
except KeyboardInterrupt:
|
|
1052
|
+
reading.clear()
|
|
928
1053
|
stop()
|
|
929
1054
|
raise
|
|
1055
|
+
reading.clear()
|
|
930
1056
|
code = proc.wait()
|
|
931
1057
|
if held_open and sandbox and token:
|
|
932
1058
|
sandbox.kill(token) # what it left running in the container
|
|
@@ -935,21 +1061,22 @@ class Toolbox:
|
|
|
935
1061
|
"\n[lcode stopped what this command left running (started with &). To keep a process running, "
|
|
936
1062
|
"start it with background=true: then bash_output reads its output and bash_stop stops it]"
|
|
937
1063
|
)
|
|
938
|
-
if
|
|
939
|
-
out.
|
|
1064
|
+
if last and last != "\n": # the blank line printed before the marker isn't output
|
|
1065
|
+
out.add(last)
|
|
1066
|
+
recent.append(last)
|
|
940
1067
|
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
941
1068
|
if sandbox and not sandbox.contains(Path(recorded).resolve()):
|
|
942
1069
|
status += "\n[That directory is outside the project; the working directory stays the same]"
|
|
943
1070
|
else:
|
|
944
1071
|
self.agent.cwd = Path(recorded).resolve()
|
|
945
1072
|
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
946
|
-
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(
|
|
1073
|
+
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(recent)):
|
|
947
1074
|
status += (
|
|
948
1075
|
"\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
|
|
949
1076
|
"with /sandbox network on]"
|
|
950
1077
|
)
|
|
951
1078
|
self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
|
|
952
|
-
return
|
|
1079
|
+
return out.text() + status + f"\n[exit code: {code}]"
|
|
953
1080
|
|
|
954
1081
|
# -- web
|
|
955
1082
|
def _web_allowed(self, key: str, title: str, detail: str) -> tuple[bool, str]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.28.
|
|
3
|
+
Version: 0.28.1
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=USdcfUSBcDvzY7_LoT19i6HbdafE2NIPUyufHNoj9Vo,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=ws5i476DOsaEIv5YkYQ_qfmRV5fPitd6QltLDU9QhpE,64804
|
|
6
|
+
lcode/api.py,sha256=Ul_2H_6LzkufaXHaG0Dz1vDIEmOAZ31VHO2qsjVdtE8,15506
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
@@ -14,7 +14,7 @@ lcode/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
|
|
|
14
14
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
15
15
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
16
16
|
lcode/config.py,sha256=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
|
|
17
|
-
lcode/context.py,sha256=
|
|
17
|
+
lcode/context.py,sha256=WSMiitscDh2Juy4Hx0x9EPNWrn804IbOWRZly1-DU38,17022
|
|
18
18
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
19
19
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
20
20
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
@@ -27,18 +27,18 @@ lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
|
27
27
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
28
28
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
29
29
|
lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
30
|
-
lcode/permissions.py,sha256=
|
|
30
|
+
lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
|
|
31
31
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
32
32
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
33
33
|
lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
|
|
34
|
-
lcode/repl.py,sha256=
|
|
34
|
+
lcode/repl.py,sha256=ayrKySMbbRbax_WMjqYBN4fkcDpzuHniefJC3DZ4-2Y,50687
|
|
35
35
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
36
36
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
37
37
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
38
38
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
39
39
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
40
40
|
lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
|
|
41
|
-
lcode/tools.py,sha256=
|
|
41
|
+
lcode/tools.py,sha256=h7C6r27_cBoCkW8tZ4BLmxdgRByYa2cUKh8AWfmEucI,54193
|
|
42
42
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
43
43
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
44
44
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -58,8 +58,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
58
58
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
59
59
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
60
60
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
61
|
-
lcode_cli-0.28.
|
|
62
|
-
lcode_cli-0.28.
|
|
63
|
-
lcode_cli-0.28.
|
|
64
|
-
lcode_cli-0.28.
|
|
65
|
-
lcode_cli-0.28.
|
|
61
|
+
lcode_cli-0.28.1.dist-info/METADATA,sha256=96ojptFvjK6CP51H1expKcg1ZtjKmxSQce4hOk052XI,12620
|
|
62
|
+
lcode_cli-0.28.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
63
|
+
lcode_cli-0.28.1.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
64
|
+
lcode_cli-0.28.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
65
|
+
lcode_cli-0.28.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|