lcode-cli 0.27.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 +153 -18
- lcode/api.py +2 -0
- lcode/context.py +68 -0
- lcode/permissions.py +153 -31
- lcode/repl.py +25 -4
- lcode/residency.py +167 -0
- lcode/tools.py +162 -36
- {lcode_cli-0.27.0.dist-info → lcode_cli-0.28.1.dist-info}/METADATA +1 -1
- {lcode_cli-0.27.0.dist-info → lcode_cli-0.28.1.dist-info}/RECORD +13 -12
- {lcode_cli-0.27.0.dist-info → lcode_cli-0.28.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.27.0.dist-info → lcode_cli-0.28.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.27.0.dist-info → lcode_cli-0.28.1.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import contextlib
|
|
6
7
|
import datetime as dt
|
|
7
8
|
import json
|
|
9
|
+
import os
|
|
8
10
|
import platform
|
|
9
11
|
import re
|
|
10
12
|
import shutil
|
|
@@ -23,7 +25,20 @@ from rich.markup import escape
|
|
|
23
25
|
from rich.panel import Panel
|
|
24
26
|
from rich.text import Text
|
|
25
27
|
|
|
26
|
-
from lcode import
|
|
28
|
+
from lcode import (
|
|
29
|
+
catalog,
|
|
30
|
+
codesearch,
|
|
31
|
+
extensions,
|
|
32
|
+
limits,
|
|
33
|
+
notify,
|
|
34
|
+
planning,
|
|
35
|
+
repomap,
|
|
36
|
+
residency,
|
|
37
|
+
sessions,
|
|
38
|
+
subagents,
|
|
39
|
+
vision,
|
|
40
|
+
web,
|
|
41
|
+
)
|
|
27
42
|
from lcode import context as context_tools
|
|
28
43
|
from lcode import memory as memory_notes
|
|
29
44
|
from lcode.checkpoints import Checkpoints
|
|
@@ -53,7 +68,12 @@ from lcode.tools import (
|
|
|
53
68
|
truncate,
|
|
54
69
|
)
|
|
55
70
|
|
|
56
|
-
GPU_MEMORY_ERRORS = (
|
|
71
|
+
GPU_MEMORY_ERRORS = (
|
|
72
|
+
"out of memory",
|
|
73
|
+
"illegal memory access",
|
|
74
|
+
"cudamalloc failed",
|
|
75
|
+
"requires more system memory", # Ollama: the model and its context don't fit in RAM either
|
|
76
|
+
)
|
|
57
77
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
58
78
|
MAX_STEPS_PER_TURN = 150
|
|
59
79
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
@@ -62,6 +82,9 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
|
|
|
62
82
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
63
83
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
64
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
|
|
87
|
+
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
65
88
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
66
89
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
67
90
|
# @path, @"path with spaces" or @'path'
|
|
@@ -171,6 +194,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
|
|
|
171
194
|
return work_tree_for(cwd)
|
|
172
195
|
|
|
173
196
|
|
|
197
|
+
def conversation_chars(messages: list[dict]) -> int:
|
|
198
|
+
return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
|
|
199
|
+
|
|
200
|
+
|
|
174
201
|
def call_arguments(call: dict) -> dict:
|
|
175
202
|
"""A tool call's arguments as a dict (some models send them as a JSON string)."""
|
|
176
203
|
args = call.get("function", {}).get("arguments") or {}
|
|
@@ -189,17 +216,22 @@ class Progress:
|
|
|
189
216
|
arrives, for example while the model writes a long file into a tool call.
|
|
190
217
|
"""
|
|
191
218
|
|
|
192
|
-
def __init__(self) -> None:
|
|
219
|
+
def __init__(self, phase: str = "Thinking") -> None:
|
|
193
220
|
self.started = self.last_chunk = time.time()
|
|
194
|
-
self.phase =
|
|
221
|
+
self.phase = phase
|
|
195
222
|
self.chars = 0
|
|
196
223
|
|
|
197
224
|
def update(self, phase: str, text: str) -> None:
|
|
198
225
|
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
199
226
|
|
|
227
|
+
def set_phase(self, phase: str) -> None:
|
|
228
|
+
"""What happens before the first token: loading the model, reading the prompt."""
|
|
229
|
+
self.phase, self.last_chunk = phase, time.time()
|
|
230
|
+
|
|
200
231
|
def __rich__(self) -> Text:
|
|
201
232
|
now = time.time()
|
|
202
|
-
|
|
233
|
+
streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
|
|
234
|
+
phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
|
|
203
235
|
return Text.from_markup(
|
|
204
236
|
f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
|
|
205
237
|
)
|
|
@@ -288,6 +320,12 @@ class Agent:
|
|
|
288
320
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
289
321
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
290
322
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
323
|
+
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
324
|
+
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
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)
|
|
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)
|
|
291
329
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
292
330
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
293
331
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -465,12 +503,33 @@ class Agent:
|
|
|
465
503
|
image = vision.read_image(path)
|
|
466
504
|
# The session's own model keeps its settings, so Ollama doesn't reload it.
|
|
467
505
|
options = self.options() if model == self.settings.model else None
|
|
468
|
-
|
|
469
|
-
with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
|
|
506
|
+
with self.vision_swap(model, path.name):
|
|
470
507
|
return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
|
|
471
508
|
except vision.VisionError as e:
|
|
472
509
|
raise ToolError(str(e)) from e
|
|
473
510
|
|
|
511
|
+
@contextlib.contextmanager
|
|
512
|
+
def vision_swap(self, model: str, what: str):
|
|
513
|
+
"""Look at an image with `model`, saying when it has to load and when the main model made room for it."""
|
|
514
|
+
main = self.settings.model
|
|
515
|
+
own = model == main
|
|
516
|
+
main_was_loaded = not own and residency.placement(self.ollama, main) is not None
|
|
517
|
+
if own or residency.placement(self.ollama, model) is not None:
|
|
518
|
+
text = f"Looking at {what} with {model}…"
|
|
519
|
+
else:
|
|
520
|
+
text = f"Loading the vision model {model} to look at {what}" + (
|
|
521
|
+
f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
|
|
522
|
+
)
|
|
523
|
+
with self.console.status(text, spinner="dots"):
|
|
524
|
+
yield
|
|
525
|
+
if main_was_loaded and residency.placement(self.ollama, main) is None:
|
|
526
|
+
self.made_room_for = f"the vision model {model}"
|
|
527
|
+
self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
|
|
528
|
+
|
|
529
|
+
def say(self, text: str, warning: bool = False) -> None:
|
|
530
|
+
"""One line about what happens under the hood (loading, memory), not about the work itself."""
|
|
531
|
+
self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
|
|
532
|
+
|
|
474
533
|
def describe_image_data(self, data: str, mime: str) -> str:
|
|
475
534
|
"""For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
|
|
476
535
|
model = self.vision_model()
|
|
@@ -478,7 +537,7 @@ class Agent:
|
|
|
478
537
|
return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
|
|
479
538
|
options = self.options() if model == self.settings.model else None
|
|
480
539
|
try:
|
|
481
|
-
with self.
|
|
540
|
+
with self.vision_swap(model, f"the {mime} image"):
|
|
482
541
|
text = vision.describe(
|
|
483
542
|
self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
|
|
484
543
|
)
|
|
@@ -522,6 +581,7 @@ class Agent:
|
|
|
522
581
|
self.tools.read_mtimes.clear()
|
|
523
582
|
self.reflected = len(self.messages)
|
|
524
583
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
584
|
+
self.counted_chars = None
|
|
525
585
|
|
|
526
586
|
def session_file(self) -> Path:
|
|
527
587
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -543,7 +603,10 @@ class Agent:
|
|
|
543
603
|
"messages": self.messages,
|
|
544
604
|
"checkpoints": self.checkpoints.to_json(),
|
|
545
605
|
}
|
|
546
|
-
|
|
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)
|
|
547
610
|
|
|
548
611
|
def new_session(self) -> None:
|
|
549
612
|
self.reset()
|
|
@@ -580,6 +643,7 @@ class Agent:
|
|
|
580
643
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
581
644
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
582
645
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
646
|
+
self.counted_chars = None
|
|
583
647
|
return note
|
|
584
648
|
|
|
585
649
|
def load_latest(self) -> bool:
|
|
@@ -624,9 +688,11 @@ class Agent:
|
|
|
624
688
|
if not started and batch and batch > SAFE_NUM_BATCH:
|
|
625
689
|
# Larger batches read prompts faster but need extra VRAM that isn't always free.
|
|
626
690
|
self.settings.num_batch = SAFE_NUM_BATCH
|
|
627
|
-
self.
|
|
628
|
-
f"
|
|
629
|
-
f"{SAFE_NUM_BATCH}
|
|
691
|
+
self.say(
|
|
692
|
+
f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
|
|
693
|
+
f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
|
|
694
|
+
f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
|
|
695
|
+
warning=True,
|
|
630
696
|
)
|
|
631
697
|
yield from self.chat(messages, tools, think)
|
|
632
698
|
return
|
|
@@ -636,16 +702,19 @@ class Agent:
|
|
|
636
702
|
smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
|
|
637
703
|
self.settings.context = smaller
|
|
638
704
|
limits.record(self.settings.model, smaller)
|
|
639
|
-
self.
|
|
640
|
-
f"
|
|
641
|
-
|
|
642
|
-
"time on this machine.
|
|
705
|
+
self.say(
|
|
706
|
+
f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
|
|
707
|
+
"context needs its own memory next to the model. Reloading it with "
|
|
708
|
+
f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
|
|
709
|
+
warning=True,
|
|
643
710
|
)
|
|
644
711
|
yield from self.chat(messages, tools, think)
|
|
645
712
|
return
|
|
646
713
|
raise OllamaError(
|
|
647
|
-
f"{e}\
|
|
648
|
-
"
|
|
714
|
+
f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
|
|
715
|
+
"model, a game, an image generator), use a smaller context (/ctx "
|
|
716
|
+
f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
|
|
717
|
+
"model (/models)."
|
|
649
718
|
) from e
|
|
650
719
|
raise
|
|
651
720
|
|
|
@@ -659,6 +728,7 @@ class Agent:
|
|
|
659
728
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
660
729
|
md = MarkdownStreamer(self.console)
|
|
661
730
|
progress = Progress()
|
|
731
|
+
watch = self.before_request(progress)
|
|
662
732
|
status = None
|
|
663
733
|
printed_thinking = False
|
|
664
734
|
|
|
@@ -680,6 +750,9 @@ class Agent:
|
|
|
680
750
|
if self.cancel is not None and self.cancel.is_set():
|
|
681
751
|
raise KeyboardInterrupt
|
|
682
752
|
msg = chunk.get("message", {})
|
|
753
|
+
if watch is not None:
|
|
754
|
+
self.after_load(watch)
|
|
755
|
+
watch = None
|
|
683
756
|
if msg.get("thinking"):
|
|
684
757
|
thinking += msg["thinking"]
|
|
685
758
|
if self.on_delta:
|
|
@@ -714,6 +787,8 @@ class Agent:
|
|
|
714
787
|
raise
|
|
715
788
|
finally:
|
|
716
789
|
stop_spinner()
|
|
790
|
+
if watch is not None:
|
|
791
|
+
watch.stop()
|
|
717
792
|
if printed_thinking:
|
|
718
793
|
self.console.print()
|
|
719
794
|
md.flush()
|
|
@@ -732,10 +807,49 @@ class Agent:
|
|
|
732
807
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
733
808
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
734
809
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
810
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
735
811
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
736
812
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
737
813
|
return message
|
|
738
814
|
|
|
815
|
+
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
816
|
+
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
817
|
+
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
818
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama":
|
|
819
|
+
return None
|
|
820
|
+
model, context = self.settings.model, self.settings.context
|
|
821
|
+
fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
|
|
822
|
+
where = residency.placement(self.ollama, model)
|
|
823
|
+
if where is not None and (not where.context or where.context == context):
|
|
824
|
+
if fresh >= READING_NOTE_TOKENS:
|
|
825
|
+
progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
|
|
826
|
+
return None
|
|
827
|
+
total = conversation_chars(self.messages) // 3
|
|
828
|
+
if where is not None:
|
|
829
|
+
phase = f"Reloading {model} with a {format_tokens(context)} context"
|
|
830
|
+
elif self.made_room_for:
|
|
831
|
+
phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
|
|
832
|
+
else:
|
|
833
|
+
phase = f"Loading {model} into memory"
|
|
834
|
+
progress.set_phase(phase)
|
|
835
|
+
watch = residency.LoadWatch(
|
|
836
|
+
self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
|
|
837
|
+
)
|
|
838
|
+
watch.reload = where is not None or bool(self.made_room_for)
|
|
839
|
+
return watch
|
|
840
|
+
|
|
841
|
+
def after_load(self, watch: residency.LoadWatch) -> None:
|
|
842
|
+
"""The first token after a load: one line on how long it took and how the model fits."""
|
|
843
|
+
seconds = watch.stop()
|
|
844
|
+
self.made_room_for = ""
|
|
845
|
+
self.load_state = ""
|
|
846
|
+
where = residency.placement(self.ollama, self.settings.model)
|
|
847
|
+
dense = residency.is_moe(self.ollama, self.settings.model) is False
|
|
848
|
+
report = residency.load_report(
|
|
849
|
+
self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
|
|
850
|
+
)
|
|
851
|
+
self.say(report, warning=report.endswith(("closed.", "slow.")))
|
|
852
|
+
|
|
739
853
|
def describe_call(self, name: str, args: dict) -> str:
|
|
740
854
|
if name in ("read_file", "write_file", "edit_file"):
|
|
741
855
|
path = self.tools.rel(self.tools.resolve(args.get("path", "")))
|
|
@@ -839,6 +953,8 @@ class Agent:
|
|
|
839
953
|
for step in range(self.max_steps):
|
|
840
954
|
if self.cancel is not None and self.cancel.is_set():
|
|
841
955
|
raise KeyboardInterrupt
|
|
956
|
+
if step and self.autosave:
|
|
957
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
842
958
|
self.maybe_compact()
|
|
843
959
|
try:
|
|
844
960
|
reply = self.assistant_step()
|
|
@@ -923,6 +1039,9 @@ class Agent:
|
|
|
923
1039
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
924
1040
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
925
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")
|
|
926
1045
|
verdict = self.repeats.check(name, args, result)
|
|
927
1046
|
if verdict.note:
|
|
928
1047
|
result += verdict.note
|
|
@@ -1057,8 +1176,21 @@ class Agent:
|
|
|
1057
1176
|
limit = min(limit, self.settings.compact_at)
|
|
1058
1177
|
return limit
|
|
1059
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
|
+
|
|
1060
1190
|
def maybe_compact(self) -> None:
|
|
1061
1191
|
limit = self.compact_limit()
|
|
1192
|
+
if self.next_request_tokens() > limit:
|
|
1193
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1062
1194
|
if self.ctx_used <= limit:
|
|
1063
1195
|
return
|
|
1064
1196
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1071,6 +1203,7 @@ class Agent:
|
|
|
1071
1203
|
) # not enough: before the last request
|
|
1072
1204
|
context_tools.note_removed(self.messages, removed)
|
|
1073
1205
|
if saved:
|
|
1206
|
+
self.sent_chars = 0 # the conversation changed: the model reads it again
|
|
1074
1207
|
self.pruned += 1
|
|
1075
1208
|
self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
|
|
1076
1209
|
self.console.print(
|
|
@@ -1126,7 +1259,9 @@ class Agent:
|
|
|
1126
1259
|
)
|
|
1127
1260
|
self.skills_loaded.clear()
|
|
1128
1261
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1262
|
+
self.counted_chars = None
|
|
1129
1263
|
self.summarized_size = self.ctx_used
|
|
1264
|
+
self.sent_chars = 0
|
|
1130
1265
|
self.reflected = len(self.messages)
|
|
1131
1266
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
1132
1267
|
|
lcode/api.py
CHANGED
|
@@ -199,6 +199,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
199
199
|
compact_at=cfg["compact_at"],
|
|
200
200
|
)
|
|
201
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
|
+
agent.hardware = hw
|
|
203
|
+
agent.autosave = True
|
|
202
204
|
agent.interactive = options.interactive
|
|
203
205
|
if options.max_steps:
|
|
204
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
|