lcode-cli 0.31.0__py3-none-any.whl → 0.33.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.31.0"
6
+ __version__ = "0.33.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -35,6 +35,7 @@ from lcode import (
35
35
  planning,
36
36
  repomap,
37
37
  residency,
38
+ review,
38
39
  sessions,
39
40
  subagents,
40
41
  vision,
@@ -51,6 +52,7 @@ from lcode.permissions import Permissions
51
52
  from lcode.render import MarkdownStreamer
52
53
  from lcode.repeats import Repeats
53
54
  from lcode.sandbox import Sandbox, SandboxError, project_root
55
+ from lcode.steer import NOTE_INTRO, Steering
54
56
  from lcode.tools import (
55
57
  LSP_SCHEMA,
56
58
  MEMORY_SCHEMA,
@@ -223,10 +225,12 @@ class Progress:
223
225
  arrives, for example while the model writes a long file into a tool call.
224
226
  """
225
227
 
226
- def __init__(self, phase: str = "Thinking") -> None:
228
+ def __init__(self, phase: str = "Thinking", steering: Steering | None = None, tokens: bool = True) -> None:
227
229
  self.started = self.last_chunk = time.time()
228
230
  self.phase = phase
229
231
  self.chars = 0
232
+ self.steering = steering # the note being typed and the notes waiting, shown on a second line
233
+ self.tokens = tokens # False for a command running: no tokens to count
230
234
 
231
235
  def update(self, phase: str, text: str) -> None:
232
236
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
@@ -239,9 +243,11 @@ class Progress:
239
243
  now = time.time()
240
244
  streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
241
245
  phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
242
- return Text.from_markup(
243
- f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
244
- )
246
+ tokens = f"{self.chars // 4} tokens, " if self.tokens else ""
247
+ keys = "Ctrl+C to stop" if self.steering is None else "type a note · Esc pauses · Ctrl+C stops"
248
+ line = Text.from_markup(f"[cyan]{phase}…[/] [dim]({tokens}{now - self.started:.0f}s · {keys})[/]")
249
+ below = self.steering.status() if self.steering is not None else None
250
+ return line if below is None else Text("\n").join([line, below])
245
251
 
246
252
 
247
253
  @dataclass
@@ -276,6 +282,7 @@ class Settings:
276
282
  compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
277
283
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
278
284
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
285
+ review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
279
286
 
280
287
 
281
288
  class Agent:
@@ -319,6 +326,9 @@ class Agent:
319
326
  self.read_only = False # only read-only shell commands
320
327
  self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
321
328
  self.cancel: threading.Event | None = None # set from another thread to stop
329
+ self.steering: Steering | None = None # notes typed and pauses asked for during a request (lcode.repl)
330
+ self.turn_requests: list[str] = [] # the request, then the notes added to it while it ran
331
+ self.unsent = "" # typed during the last request but never sent to the model: back in the prompt (lcode.repl)
322
332
  self.on_tool = None # called with (name, arguments) before each tool runs
323
333
  self.jobs = Jobs() # background commands (shared with subagents)
324
334
  self.turn_started = 0.0 # when the running request started (time.monotonic)
@@ -334,6 +344,7 @@ class Agent:
334
344
  self.autosave = False # save the session after every step (sessions people resume; not the bench's)
335
345
  self._check: tuple[Path, checks.Check | None] | None = None
336
346
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
347
+ self.review_results: list[str] | None = None # what the last request's review found (None: no review)
337
348
  self.sent_chars = 0 # characters of the conversation the model had already read
338
349
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
339
350
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -746,7 +757,7 @@ class Agent:
746
757
  """Stream one model response, rendering thinking/content live. Returns the assistant message."""
747
758
  content, thinking, tool_calls, final = "", "", [], {}
748
759
  md = MarkdownStreamer(self.console)
749
- progress = Progress()
760
+ progress = Progress(steering=self.steering)
750
761
  watch = self.before_request(progress)
751
762
  status = None
752
763
  printed_thinking = False
@@ -831,6 +842,43 @@ class Agent:
831
842
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
832
843
  return message
833
844
 
845
+ def reviewing(self) -> bool:
846
+ return (
847
+ self.settings.review != "off" and self.allowed_tools is None and not self.planning() and not self.no_changes
848
+ )
849
+
850
+ def self_review(self, answer: str) -> list[str]:
851
+ """Check the request's change against the request before the answer counts; the problems found."""
852
+ budget = int(self.settings.context * 3 * 0.25)
853
+ diff, changed = review.diff_of(self.tools.before, self.current_text, budget)
854
+ if changed < review.MIN_CHANGED_LINES:
855
+ return []
856
+ request = self.turn_requests[0] if self.turn_requests else ""
857
+ if len(self.turn_requests) > 1:
858
+ added = "\n".join(f"- {note}" for note in self.turn_requests[1:])
859
+ request += f"\n\nWhat the user added while you worked:\n{added}"
860
+ try:
861
+ with self.console.status("Reviewing the change against your request…", spinner="dots"):
862
+ problems = review.run(self, request, answer, diff)
863
+ except OllamaError as e:
864
+ self.say(f"The review didn't run: {e}", warning=True)
865
+ return []
866
+ self.review_results = problems
867
+ if problems:
868
+ self.say(
869
+ f"Review: {len(problems)} point(s) sent back to the model. First: {problems[0][:160]}", warning=True
870
+ )
871
+ else:
872
+ self.say("Review: the change does what was asked.")
873
+ return problems
874
+
875
+ def current_text(self, path: str) -> str | None:
876
+ p = self.tools.resolve(path)
877
+ try:
878
+ return self.tools.read_text(p) if p.is_file() else None
879
+ except OSError:
880
+ return None
881
+
834
882
  def checks_prompt(self) -> str:
835
883
  check = self.project_check()
836
884
  if check is None:
@@ -1024,8 +1072,12 @@ class Agent:
1024
1072
  self.turn_status = "running"
1025
1073
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
1026
1074
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
1075
+ self.turn_requests = [user_text.strip()]
1027
1076
  self.repeats.reset()
1028
1077
  self.check_results = []
1078
+ self.review_results = None
1079
+ self.tools.before = {}
1080
+ reviewed = False
1029
1081
  changed = verified = False # files changed in this request; the checks passed since
1030
1082
  check_rounds = 0
1031
1083
  malformed = 0
@@ -1033,6 +1085,8 @@ class Agent:
1033
1085
  for step in range(self.max_steps):
1034
1086
  if self.cancel is not None and self.cancel.is_set():
1035
1087
  raise KeyboardInterrupt
1088
+ if step and self.steering is not None:
1089
+ self.steer()
1036
1090
  if step and self.autosave:
1037
1091
  self.save() # a crash or a closed terminal loses at most this step
1038
1092
  self.maybe_compact()
@@ -1078,6 +1132,8 @@ class Agent:
1078
1132
  )
1079
1133
  continue
1080
1134
  if not calls:
1135
+ if self.steering is not None and self.steering.waiting():
1136
+ continue # the user added a note while the model wrote its answer: it reads the note first
1081
1137
  check = self.project_check() if changed and not verified else None
1082
1138
  if check is not None and check_rounds < checks.MAX_ROUNDS:
1083
1139
  check_rounds += 1
@@ -1087,6 +1143,12 @@ class Agent:
1087
1143
  changed = False # run them again only if the model changes something more
1088
1144
  continue
1089
1145
  verified = True
1146
+ if not reviewed and self.tools.before and self.reviewing():
1147
+ reviewed = True
1148
+ problems = self.self_review(reply.get("content") or "")
1149
+ if problems:
1150
+ self.messages.append({"role": "user", "content": review.feedback(problems)})
1151
+ continue
1090
1152
  break
1091
1153
  done: dict[int, str] = {} # results of subagents that ran together
1092
1154
  together = [
@@ -1187,6 +1249,34 @@ class Agent:
1187
1249
  self.turn_status = "success"
1188
1250
  self.print_stats()
1189
1251
 
1252
+ def steer(self) -> None:
1253
+ """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1254
+ steering = self.steering
1255
+ if steering is None:
1256
+ return
1257
+ if steering.take_pause():
1258
+ self.console.print(
1259
+ Text(
1260
+ "⏸ Paused. Press Enter to go on, type a note to go on with it, or press Ctrl+C to stop the request.",
1261
+ style="yellow",
1262
+ )
1263
+ )
1264
+ note = steering.ask(" ⏸ ").strip()
1265
+ if note:
1266
+ steering.add(note)
1267
+ notes = steering.take()
1268
+ if not notes:
1269
+ return
1270
+ self.turn_requests += notes
1271
+ self.messages.append({"role": "user", "content": "\n\n".join(notes) + "\n\n" + NOTE_INTRO})
1272
+ for note in notes:
1273
+ shown = note if len(note) <= 200 else note[:200] + "…"
1274
+ self.console.print(Text(f"✎ Note for the model: {shown}", style="cyan"))
1275
+
1276
+ def activity(self, phase: str):
1277
+ """A spinner with the elapsed time while something runs (a command), with the note being typed under it."""
1278
+ return self.console.status(Progress(phase, self.steering, tokens=False), spinner="dots")
1279
+
1190
1280
  def print_stats(self) -> None:
1191
1281
  pct = 100 * self.ctx_used / self.settings.context
1192
1282
  self.console.print(
lcode/api.py CHANGED
@@ -77,6 +77,7 @@ class Result:
77
77
  files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
78
78
  usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
79
79
  checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
80
+ review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
80
81
  seconds: float = 0.0
81
82
  error: str = ""
82
83
 
@@ -198,6 +199,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
198
199
  secret_check=cfg["secret_check"],
199
200
  repeat_limit=cfg["repeat_limit"],
200
201
  checks=cfg["checks"],
202
+ review=cfg["review"],
201
203
  compact_at=cfg["compact_at"],
202
204
  )
203
205
  agent = Agent(ollama, settings, cwd, console=console)
@@ -306,6 +308,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
306
308
  files_changed=files,
307
309
  usage=usage,
308
310
  checks=list(agent.check_results),
311
+ review=agent.review_results,
309
312
  seconds=round(time.monotonic() - started, 2),
310
313
  error=error,
311
314
  )
lcode/cli.py CHANGED
@@ -581,6 +581,7 @@ def cmd_bench(args) -> None:
581
581
  prune=not args.no_prune,
582
582
  compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
583
583
  checks=cfg["checks"] if args.checks is None else args.checks,
584
+ review=cfg["review"] if args.review is None else args.review,
584
585
  repo_map=args.repo_map,
585
586
  embed_model=cfg["embed_model"] if args.code_search else "off",
586
587
  )
@@ -775,6 +776,9 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
775
776
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
776
777
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
777
778
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
779
+ p.add_argument(
780
+ "--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
781
+ )
778
782
  p.add_argument(
779
783
  "--checks",
780
784
  metavar="auto|off|COMMAND",
lcode/config.py CHANGED
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
+ "review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
62
63
  "checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
63
64
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
64
65
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
lcode/permissions.py CHANGED
@@ -198,6 +198,7 @@ class Permissions:
198
198
  self.always: set[str] = set()
199
199
  self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
200
200
  self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
201
+ self.read_line: Callable[[str], str] | None = None # asks instead of input() (lcode.steer, during a request)
201
202
  # Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
202
203
  self.approve: Callable[[dict], bool | str] | None = None
203
204
 
@@ -243,7 +244,8 @@ class Permissions:
243
244
  if kind == "mcp":
244
245
  scope = scope.replace(":", " › ", 1)
245
246
  try:
246
- answer = input(f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): ")
247
+ question = f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): "
248
+ answer = (self.read_line or input)(question)
247
249
  except EOFError:
248
250
  return False, "The user denied this action (no input available)."
249
251
  answer = answer.strip()
lcode/repl.py CHANGED
@@ -36,6 +36,7 @@ from lcode.checkpoints import Checkpoint, CheckpointError, Restore
36
36
  from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
37
37
  from lcode.hardware import Hardware
38
38
  from lcode.ollama import OllamaError
39
+ from lcode.steer import KeyReader, Steering
39
40
  from lcode.tools import IGNORE_DIRS
40
41
 
41
42
  COMMANDS = {
@@ -874,6 +875,7 @@ def git_command(agent: Agent, cmd: str, arg: str) -> None:
874
875
 
875
876
  def run_safely(agent: Agent, text: str) -> None:
876
877
  started = time.monotonic()
878
+ reader = start_steering(agent) if agent.interactive else None
877
879
  try:
878
880
  agent.run_turn(text)
879
881
  except KeyboardInterrupt:
@@ -881,10 +883,33 @@ def run_safely(agent: Agent, text: str) -> None:
881
883
  except OllamaError as e:
882
884
  agent.console.print(f"[red]{e}[/]")
883
885
  finally:
886
+ if reader is not None:
887
+ stop_steering(agent, reader)
884
888
  agent.save()
885
889
  agent.finished(text, time.monotonic() - started)
886
890
 
887
891
 
892
+ def start_steering(agent: Agent) -> KeyReader | None:
893
+ """Read the keyboard while the request runs: a typed line becomes a note for the model, Esc pauses."""
894
+ steering = Steering()
895
+ reader = KeyReader(steering)
896
+ if not reader.start():
897
+ return None # not a terminal lcode can read keys from (or Windows): the request runs as before
898
+ agent.steering = steering
899
+ agent.perms.read_line = steering.ask # questions take the keyboard back meanwhile
900
+ return reader
901
+
902
+
903
+ def stop_steering(agent: Agent, reader: KeyReader) -> None:
904
+ reader.stop()
905
+ agent.steering = None
906
+ agent.perms.read_line = None
907
+ unsent = reader.steering.leftover()
908
+ if unsent:
909
+ agent.unsent = unsent
910
+ agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
911
+
912
+
888
913
  def session_start(agent: Agent) -> None:
889
914
  """session_start hooks: their output is shown, and with feedback = true also given to the model."""
890
915
  for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
@@ -1115,13 +1140,13 @@ def resume_session(agent: Agent, info: sessions.SessionInfo) -> None:
1115
1140
 
1116
1141
  def print_recap(agent: Agent) -> None:
1117
1142
  """Show the last request and the start of the last answer, so it's clear where things left off."""
1118
- last_user = next((m for m in reversed(agent.messages) if m.get("role") == "user"), None)
1143
+ requests = context_tools.user_requests(agent.messages) # the user's own words, not lcode's notes to the model
1119
1144
  last_answer = next(
1120
1145
  (m for m in reversed(agent.messages) if m.get("role") == "assistant" and m.get("content", "").strip()), None
1121
1146
  )
1122
- if not last_user:
1147
+ if not requests:
1123
1148
  return
1124
- lines = [f"[bold]You:[/] {escape(sessions.title_from([last_user]))}"]
1149
+ lines = [f"[bold]You:[/] {escape(sessions.title_from([{'role': 'user', 'content': requests[-1]}]))}"]
1125
1150
  if last_answer:
1126
1151
  answer = " ".join(last_answer["content"].split())
1127
1152
  answer = answer if len(answer) <= 300 else answer[:299].rstrip() + "…"
@@ -1153,7 +1178,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1153
1178
  while True:
1154
1179
  try:
1155
1180
  agent.console.print()
1156
- line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>")).strip()
1181
+ unsent, agent.unsent = agent.unsent, ""
1182
+ line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>"), default=unsent).strip()
1157
1183
  except KeyboardInterrupt:
1158
1184
  continue
1159
1185
  except EOFError:
lcode/review.py ADDED
@@ -0,0 +1,103 @@
1
+ """A second look at a change before the answer goes to the user.
2
+
3
+ Models claim more than they did: "users can now shrink the window" without having checked, half of a
4
+ two-part request done, a debug print left behind. So when a request changed files, lcode asks the model to
5
+ step back and check its change against the request (what was asked, what the diff shows, what the answer
6
+ claims) before the answer counts. Real problems go back to the model once; the user sees a line about it.
7
+
8
+ The review is asked on top of the conversation, not in a fresh one: the model server keeps the processed
9
+ conversation cached, and a separate review prompt would push it out, so the next step would have to read the
10
+ whole conversation again, tens of seconds on a small GPU. The question tells the model to judge only from the
11
+ request and the diff.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ from typing import TYPE_CHECKING
18
+
19
+ from lcode.gitflow import parse_json
20
+
21
+ if TYPE_CHECKING:
22
+ from lcode.agent import Agent
23
+
24
+ MIN_CHANGED_LINES = 1 # even one line: a one-line bug fix is where a wrong fix hides
25
+ MAX_PROBLEMS = 5
26
+ SCHEMA = {
27
+ "type": "object",
28
+ "properties": {"problems": {"type": "array", "items": {"type": "string"}}},
29
+ "required": ["problems"],
30
+ }
31
+ PROMPT = """[lcode] Before your answer goes to the user, review your change as a strict reviewer would: judge it only by what the user asked and what the diff shows, not by what you meant to do.
32
+
33
+ The user's request:
34
+ <request>
35
+ {request}
36
+ </request>
37
+
38
+ Your answer to them:
39
+ <answer>
40
+ {answer}
41
+ </answer>
42
+
43
+ {checks}The change (unified diff of the files changed in this request):
44
+ <diff>
45
+ {diff}
46
+ </diff>
47
+
48
+ Find real problems only, each one you can point to in the request or the diff:
49
+ 1. Something the user asked for that the change doesn't do, or only partly does.
50
+ 2. A claim in your answer that the diff doesn't support, or something called tested or working that nothing shows was run.
51
+ 3. A mistake in the diff: a bug, a broken edge case the request mentions, leftover debugging (prints, commented-out code), an unfinished TODO.
52
+ No style preferences, no suggestions beyond the request. Answer in JSON: {{"problems": ["…"]}}, with an empty list when the change does what was asked."""
53
+
54
+ FEEDBACK = """[lcode] A review of your change against the user's request found:
55
+ {problems}
56
+
57
+ Fix what's real; if a point is wrong, say why in your answer. Then finish."""
58
+
59
+
60
+ def diff_of(before: dict[str, str | None], read, budget: int) -> tuple[str, int]:
61
+ """The unified diff of the files changed in the request, and how many lines it changes."""
62
+ parts, changed = [], 0
63
+ for path, old in before.items():
64
+ new = read(path)
65
+ if new is None or new == old:
66
+ continue
67
+ lines = list(
68
+ difflib.unified_diff((old or "").splitlines(True), new.splitlines(True), f"a/{path}", f"b/{path}", n=2)
69
+ )
70
+ changed += sum(1 for line in lines if line[:1] in "+-" and not line.startswith(("+++", "---")))
71
+ parts.append("".join(lines))
72
+ text = "".join(parts)
73
+ if len(text) > budget:
74
+ text = text[:budget] + f"\n[… the rest of the diff ({len(text) - budget} characters) is cut]"
75
+ return text, changed
76
+
77
+
78
+ def run(agent: Agent, request: str, answer: str, diff: str) -> list[str]:
79
+ """Ask for the review; the problems it found (empty when none, or when the answer can't be read)."""
80
+ checks = ""
81
+ if agent.check_results:
82
+ last = agent.check_results[-1]
83
+ code = last.get("exit_code")
84
+ state = "passed" if code == 0 else "didn't run" if code is None else f"failed (exit code {code})"
85
+ checks = f"The project's checks (`{last.get('command')}`) {state} after the change.\n\n"
86
+ prompt = PROMPT.format(request=request[:4000], answer=answer[:3000] or "(none)", checks=checks, diff=diff)
87
+ payload = {
88
+ "model": agent.settings.model,
89
+ "messages": [*agent.messages, {"role": "user", "content": prompt}],
90
+ "tools": agent.tool_schemas(), # as in the session, so the cached conversation is reused
91
+ "think": agent.settings.think, # without reasoning it missed every gap it caught with it, and wasn't faster
92
+ "format": SCHEMA,
93
+ "keep_alive": agent.settings.keep_alive,
94
+ "options": agent.options(),
95
+ }
96
+ reply = agent.ollama.chat(payload)
97
+ data = parse_json((reply.get("message") or {}).get("content") or "")
98
+ problems = data.get("problems") if isinstance(data.get("problems"), list) else []
99
+ return [str(p).strip()[:500] for p in problems if str(p).strip()][:MAX_PROBLEMS]
100
+
101
+
102
+ def feedback(problems: list[str]) -> str:
103
+ return FEEDBACK.format(problems="\n".join(f"- {p}" for p in problems))
lcode/sessions.py CHANGED
@@ -45,7 +45,9 @@ def title_from(messages: list[dict]) -> str:
45
45
  if content.startswith(SUMMARY_PREFIX):
46
46
  compacted = True
47
47
  continue
48
- text = re.split(r"\n\n<(?:file|directory) path=", content, maxsplit=1)[0]
48
+ if content.startswith("[lcode]"):
49
+ continue # lcode's own message to the model (a failed check, a review), not the user's words
50
+ text = re.split(r"\n\n(?:<(?:file|directory|image) path=|\[lcode[:\]] )", content, maxsplit=1)[0]
49
51
  text = " ".join(text.split())
50
52
  if text:
51
53
  return text if len(text) <= TITLE_LENGTH else text[: TITLE_LENGTH - 1].rstrip() + "…"
lcode/steer.py ADDED
@@ -0,0 +1,287 @@
1
+ """Steer a request while it runs: type a note for the model, or press Esc to pause after the step.
2
+
3
+ With a local model a request can take minutes. Ctrl+C throws away the step in progress; a note doesn't
4
+ interrupt anything: a line typed while lcode works is kept and handed to the model before its next step, and the
5
+ spinner line shows what's being typed and what's waiting. Esc asks lcode to stop after the current step, so you
6
+ can look at what it did and then go on, with or without a note.
7
+
8
+ `Steering` holds the state and turns keystrokes into notes; it knows nothing about terminals, so it's easy to
9
+ test. `KeyReader` reads the keys from the terminal in the background, without echoing them (that would garble
10
+ the output), and hands the keyboard back whenever lcode asks a question (`Steering.ask`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import codecs
16
+ import contextlib
17
+ import os
18
+ import select
19
+ import sys
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable, Iterator
23
+
24
+ from rich.text import Text
25
+
26
+ try:
27
+ import termios
28
+ except ImportError: # Windows: no steering, everything else works
29
+ termios = None # type: ignore[assignment]
30
+
31
+ MAX_NOTE = 4000 # characters in one note
32
+ SHOWN = 45 # characters of the line being typed shown under the spinner (the line fits 80 columns)
33
+ ESC_WAIT = 0.03 # an Esc followed by more bytes this soon is an arrow or function key, not the Esc key
34
+ TOO_QUICK = 0.4 # an Enter this soon after a question appears was meant for the note being typed
35
+ NOTE_INTRO = (
36
+ "[lcode] The user wrote this while you were working. Take it into account from here on; it doesn't mean "
37
+ "starting over unless it says so."
38
+ )
39
+
40
+
41
+ class Steering:
42
+ """Notes typed during a request, the line being typed, and whether to pause after the step."""
43
+
44
+ def __init__(self) -> None:
45
+ self.lock = threading.Lock()
46
+ self.typed = "" # the line being typed
47
+ self.notes: list[str] = [] # finished lines, waiting for the model's next step
48
+ self.pause = False # stop after the current step
49
+ self.reader: KeyReader | None = None
50
+ self.read_line: Callable[[str], str] | None = None # input() when None (looked up when asking)
51
+
52
+ # Input ------------------------------------------------------------------------------------------------
53
+
54
+ def feed(self, keys: str) -> None:
55
+ """Keystrokes as text: printable characters, Enter, Backspace, Ctrl+U/Ctrl+W, Esc and escape sequences.
56
+
57
+ A lone "\\x1b" is the Esc key (KeyReader waits a moment for the rest of a sequence before it hands one
58
+ over). Everything else that starts with it is skipped: the sequences arrow and function keys send,
59
+ Alt+key, and a terminal's answers to queries (cursor position, colours).
60
+ """
61
+ with self.lock:
62
+ i = 0
63
+ while i < len(keys):
64
+ ch = keys[i]
65
+ i += 1
66
+ if ch == "\x1b":
67
+ after = keys[i] if i < len(keys) else ""
68
+ if after == "[": # CSI: parameters, then a final byte in @..~
69
+ i += 1
70
+ while i < len(keys) and not ("@" <= keys[i] <= "~"):
71
+ i += 1
72
+ i += 1
73
+ elif after == "O": # SS3: one more character (F1-F4, keypad)
74
+ i += 2
75
+ elif after in ("]", "P", "_", "^"): # a string (OSC, DCS…), ended by BEL or ESC \
76
+ end = min(
77
+ (j for j in (keys.find("\x07", i), keys.find("\x1b\\", i)) if j >= 0), default=len(keys)
78
+ )
79
+ i = end + (2 if keys.startswith("\x1b\\", end) else 1)
80
+ elif after in ("", "\x1b"): # the Esc key (twice in a row: two presses)
81
+ self.pause = not self.pause
82
+ else:
83
+ i += 1 # Alt+key
84
+ elif ch in "\r\n":
85
+ note = self.typed.strip()
86
+ self.typed = ""
87
+ if note:
88
+ self.notes.append(note[:MAX_NOTE])
89
+ elif ch in "\x7f\x08":
90
+ self.typed = self.typed[:-1]
91
+ elif ch == "\x15": # Ctrl+U: clear the line
92
+ self.typed = ""
93
+ elif ch == "\x17": # Ctrl+W: delete the last word
94
+ self.typed = self.typed.rstrip()
95
+ self.typed = self.typed[: self.typed.rfind(" ") + 1] if " " in self.typed else ""
96
+ elif ch == "\t":
97
+ self.typed += " "
98
+ elif ch >= " " and len(self.typed) < MAX_NOTE:
99
+ self.typed += ch
100
+
101
+ # For the agent ----------------------------------------------------------------------------------------
102
+
103
+ def add(self, note: str) -> None:
104
+ with self.lock:
105
+ self.notes.append(note[:MAX_NOTE])
106
+
107
+ def waiting(self) -> bool:
108
+ with self.lock:
109
+ return bool(self.notes)
110
+
111
+ def take(self) -> list[str]:
112
+ """The notes waiting for the model (and forget them)."""
113
+ with self.lock:
114
+ notes, self.notes = self.notes, []
115
+ return notes
116
+
117
+ def take_pause(self) -> bool:
118
+ """Whether a pause was asked for (and clear the request)."""
119
+ with self.lock:
120
+ asked, self.pause = self.pause, False
121
+ return asked
122
+
123
+ def leftover(self) -> str:
124
+ """What never reached the model: notes still waiting and the unfinished line, for the next prompt."""
125
+ with self.lock:
126
+ text = "\n".join([*self.notes, self.typed.strip()]).strip()
127
+ self.notes, self.typed = [], ""
128
+ return text
129
+
130
+ def ask(self, question: str) -> str:
131
+ """Ask the user a question while the keyboard is being read for notes (permission prompts, the pause).
132
+
133
+ Keys typed before the question appeared are dropped, and an Enter pressed right as it appeared asks
134
+ once more: it was meant to finish a note, not to answer (an empty answer often means yes).
135
+ """
136
+ hold = self.reader.suspended() if self.reader is not None else contextlib.nullcontext()
137
+ with hold:
138
+ asked = time.monotonic()
139
+ answer = (self.read_line or input)(question)
140
+ if not answer.strip() and time.monotonic() - asked < TOO_QUICK:
141
+ answer = (self.read_line or input)(question)
142
+ return answer
143
+
144
+ def status(self) -> Text | None:
145
+ """The line under the spinner: what's being typed, notes waiting, a pause coming."""
146
+ with self.lock:
147
+ typed, waiting, pause = self.typed, len(self.notes), self.pause
148
+ parts: list[Text] = []
149
+ if typed:
150
+ shown = typed if len(typed) <= SHOWN else "…" + typed[-SHOWN:]
151
+ parts.append(Text.assemble(("✎ ", "cyan"), shown, ("▏", "cyan"), (" Enter adds it as a note", "dim")))
152
+ if waiting:
153
+ what = "a note" if waiting == 1 else f"{waiting} notes"
154
+ parts.append(Text(f"✎ {what} waiting for the model's next step", style="cyan"))
155
+ if pause:
156
+ parts.append(Text("⏸ Pausing after this step (Esc again to go on without pausing)", style="yellow"))
157
+ if not parts:
158
+ return None
159
+ return Text("\n").join(parts)
160
+
161
+
162
+ class KeyReader:
163
+ """Reads the keyboard in the background while a request runs, and gives the keys to a Steering.
164
+
165
+ The terminal stays in "cbreak" mode meanwhile: keys arrive one by one and aren't echoed, while Ctrl+C still
166
+ interrupts. `suspended()` hands the keyboard back, in its usual mode, for a question.
167
+ """
168
+
169
+ def __init__(self, steering: Steering, fd: int | None = None) -> None:
170
+ self.steering = steering
171
+ self.fd = fd # stdin when None
172
+ self.saved: list | None = None
173
+ self.thread: threading.Thread | None = None
174
+ self.stopping = threading.Event()
175
+ self.hold = threading.Event() # asked to stop reading for a while
176
+ self.held = threading.Event() # ...and the thread has stopped
177
+ self.resume = threading.Event()
178
+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
179
+
180
+ def start(self) -> bool:
181
+ """Start reading; False when this isn't a terminal lcode can read keys from."""
182
+ if termios is None:
183
+ return False
184
+ try:
185
+ if self.fd is None:
186
+ self.fd = sys.stdin.fileno()
187
+ if not os.isatty(self.fd):
188
+ return False
189
+ except (OSError, ValueError): # no real stdin (tests, some IDE consoles)
190
+ return False
191
+ try:
192
+ self.saved = termios.tcgetattr(self.fd)
193
+ self._cbreak()
194
+ except termios.error:
195
+ self.saved = None
196
+ return False
197
+ self.steering.reader = self
198
+ self.thread = threading.Thread(target=self._run, name="lcode-keys", daemon=True)
199
+ self.thread.start()
200
+ return True
201
+
202
+ def stop(self) -> None:
203
+ self.stopping.set()
204
+ self.resume.set()
205
+ if self.thread is not None:
206
+ self.thread.join(timeout=1)
207
+ self._restore()
208
+ self.steering.reader = None
209
+
210
+ def __enter__(self) -> KeyReader:
211
+ self.start()
212
+ return self
213
+
214
+ def __exit__(self, *exc) -> None:
215
+ self.stop()
216
+
217
+ @contextlib.contextmanager
218
+ def suspended(self) -> Iterator[None]:
219
+ """The keyboard back in its usual mode (echo, line editing) for a question, with typed-ahead keys dropped."""
220
+ if self.thread is None or not self.thread.is_alive():
221
+ yield
222
+ return
223
+ self.resume.clear()
224
+ self.hold.set()
225
+ self.held.wait(timeout=1)
226
+ self._restore()
227
+ with contextlib.suppress(termios.error):
228
+ termios.tcflush(self.fd, termios.TCIFLUSH)
229
+ try:
230
+ yield
231
+ finally:
232
+ with contextlib.suppress(termios.error):
233
+ self._cbreak()
234
+ self.hold.clear()
235
+ self.held.clear()
236
+ self.resume.set()
237
+
238
+ def _cbreak(self) -> None:
239
+ mode = termios.tcgetattr(self.fd)
240
+ mode[3] &= ~(termios.ECHO | termios.ICANON) # lflags; ISIG stays, so Ctrl+C still interrupts
241
+ mode[6][termios.VMIN] = 1
242
+ mode[6][termios.VTIME] = 0
243
+ termios.tcsetattr(self.fd, termios.TCSANOW, mode)
244
+
245
+ def _restore(self) -> None:
246
+ if self.saved is not None:
247
+ with contextlib.suppress(termios.error):
248
+ termios.tcsetattr(self.fd, termios.TCSANOW, self.saved)
249
+
250
+ def _run(self) -> None:
251
+ while not self.stopping.is_set():
252
+ if self.hold.is_set():
253
+ self.held.set()
254
+ self.resume.wait()
255
+ continue
256
+ try:
257
+ ready, _, _ = select.select([self.fd], [], [], 0.1)
258
+ if not ready or self.hold.is_set():
259
+ continue
260
+ data = os.read(self.fd, 1024)
261
+ if not data:
262
+ return # the terminal is gone
263
+ while incomplete_escape(data):
264
+ more, _, _ = select.select([self.fd], [], [], ESC_WAIT) # the rest of an escape sequence?
265
+ if not more:
266
+ break # a lone "\x1b" is then the Esc key
267
+ data += os.read(self.fd, 1024)
268
+ except (OSError, ValueError):
269
+ return
270
+ self.steering.feed(self.decoder.decode(data))
271
+
272
+
273
+ def incomplete_escape(data: bytes) -> bool:
274
+ """Whether the bytes end in the middle of an escape sequence (or with a "\\x1b" that may start one)."""
275
+ start = data.rfind(b"\x1b")
276
+ if start < 0:
277
+ return False
278
+ tail = data[start:]
279
+ if tail == b"\x1b":
280
+ return True
281
+ if tail.startswith(b"\x1b["):
282
+ return not any(0x40 <= b <= 0x7E for b in tail[2:])
283
+ if tail.startswith(b"\x1bO"):
284
+ return len(tail) < 3
285
+ if tail[1:2] in (b"]", b"P", b"_", b"^"):
286
+ return b"\x07" not in tail # (one ended by ESC \ has its last ESC there: complete)
287
+ return False
lcode/tools.py CHANGED
@@ -448,6 +448,7 @@ class Toolbox:
448
448
  self.formats: dict[str, TextFormat] = {} # encoding and line endings of the files read
449
449
  self.notes: list[str] = [] # the model's notes on the task (todo_write): decisions, findings, questions
450
450
  self.changed: dict[str, None] = {} # files lcode wrote in this session, in order
451
+ self.before: dict[str, str | None] = {} # files changed in this request: their text before (None: new)
451
452
  self.todos: list[dict] = []
452
453
  # An editor's buffers (lcode acp): read unsaved changes and write through the editor.
453
454
  self.reader: Callable[[Path], str | None] | None = None
@@ -870,6 +871,8 @@ class Toolbox:
870
871
  p.parent.mkdir(parents=True, exist_ok=True)
871
872
  fmt = self.formats.get(str(p)) or (decode(p.read_bytes())[1] if p.exists() else TextFormat())
872
873
  data = encode(content, fmt) # before anything is written: it may not be possible
874
+ if self.rel(p) not in self.before:
875
+ self.before[self.rel(p)] = decode(p.read_bytes())[0] if p.exists() else None
873
876
  if self.writer is not None:
874
877
  self.writer(p, content) # the editor updates its buffer (and the file)
875
878
  if not p.exists() or p.read_bytes() != data:
@@ -1118,6 +1121,8 @@ class Toolbox:
1118
1121
  deadline = time.time() + int(timeout or 180)
1119
1122
  finished = 0.0 # when the command itself ended (its marker arrived)
1120
1123
  held_open = False # something it started still holds the output open
1124
+ spinner = self.agent.activity("Running the command") # the elapsed time, and a note being typed
1125
+ spinner.start()
1121
1126
  try:
1122
1127
  while True:
1123
1128
  if self.agent.cancel is not None and self.agent.cancel.is_set():
@@ -1151,6 +1156,8 @@ class Toolbox:
1151
1156
  reading.clear()
1152
1157
  stop()
1153
1158
  raise
1159
+ finally:
1160
+ spinner.stop()
1154
1161
  reading.clear()
1155
1162
  code = proc.wait()
1156
1163
  if held_open and sandbox and token:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.31.0
3
+ Version: 0.33.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,9 +1,9 @@
1
- lcode/__init__.py,sha256=gXBLoiYa3VhO8oR-eyFbfplfXuE6GNMT6g92TwUc_2g,473
1
+ lcode/__init__.py,sha256=cScEtA6ZlYacKlh7dY1BybTR-WQqVhO0LRooeZQ6OB8,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=OkGViWlZd8g-MFZpaCnbRIpmrFXnKajbIeEMKV1e7-o,70275
6
- lcode/api.py,sha256=PhG8PZReM7DXSxG6wf7mEjAKx_TAsgj3xHApacrXGCM,15684
5
+ lcode/agent.py,sha256=fs40oIivqNV6R_Y2Yp8jHbQUywK55czn3wRHrdViyQ0,75086
6
+ lcode/api.py,sha256=YTfXVMSYSp1EC3tNI_fW-JcNBgrdFTN7V57HNIzB7Kc,15861
7
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
8
  lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
@@ -11,10 +11,10 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
13
  lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
14
- lcode/cli.py,sha256=svnW6pfLtqiMXbA3wMlYESKGgGe6Xb9wlWkqg6Cz0O0,41081
14
+ lcode/cli.py,sha256=pB8gnPuVkqanm9amuLKWanH3_0onpn8oS8botxl-DSw,41292
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
- lcode/config.py,sha256=KSjSBvxA9nmd4h4QYk3xp8BQ7C04pjTULsSn0jcQwBc,11301
17
+ lcode/config.py,sha256=CujFZiUPsbZERjPw2j_uTBnZqUfCgYz1hvcvQSSMCu0,11421
18
18
  lcode/context.py,sha256=0YYLrC7gI164HqDjW_jrgHSnfrAilrRUoTLhvw54VI8,19082
19
19
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
20
20
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
@@ -29,18 +29,20 @@ lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
29
29
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
30
30
  lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
31
31
  lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
32
- lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
32
+ lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
33
33
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
34
34
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
35
35
  lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
36
- lcode/repl.py,sha256=0f3nojURlQE8Pm_JkXvh9y7nEg1CHG3xanZIs0osPxw,52107
36
+ lcode/repl.py,sha256=_krcqKqHKsGUKo4JfQS9KQNBxA47qE1k__TAkDTusRc,53242
37
37
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
38
38
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
39
+ lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
39
40
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
40
41
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
41
- lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
42
+ lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
43
+ lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
42
44
  lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
43
- lcode/tools.py,sha256=PofQ77Y7TvsUcFcvrUDP4GirpgkQKhhC_NL_1K_8_Jo,61182
45
+ lcode/tools.py,sha256=Af4CYJUB7hCmsrNWAn5dIfI11YZFBTLclYaoEZ4Tsm4,61599
44
46
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
45
47
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
46
48
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -60,8 +62,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
60
62
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
61
63
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
62
64
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
63
- lcode_cli-0.31.0.dist-info/METADATA,sha256=kGzeEZoj0ZAZxYBHC30JRJAKERgqAq4h9XObVzJYPms,12620
64
- lcode_cli-0.31.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
65
- lcode_cli-0.31.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
66
- lcode_cli-0.31.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
67
- lcode_cli-0.31.0.dist-info/RECORD,,
65
+ lcode_cli-0.33.0.dist-info/METADATA,sha256=ZaaBzVSgt8pvJJ9rc-6vc2UkbNO4TYAxVCgTfgIcq6Q,12620
66
+ lcode_cli-0.33.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
67
+ lcode_cli-0.33.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
68
+ lcode_cli-0.33.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
69
+ lcode_cli-0.33.0.dist-info/RECORD,,