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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.28.0"
6
+ __version__ = "0.28.1"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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
- f.write_text(json.dumps(data))
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
- def bash_key(command: str) -> str:
26
- """The key "always allow" remembers: the program (plus subcommand for tools like git), ignoring `cd dir &&`."""
27
- command = re.sub(r"^\s*(cd\s+\S+\s*&&\s*)+", "", command)
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
- words = shlex.split(command)
46
+ tokens = list(lexer)
30
47
  except ValueError:
31
- words = command.split()
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 "bash:"
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 f"bash:{' '.join(words[:3])}"
37
- return f"bash:{words[0]} {words[1]}"
38
- return f"bash:{words[0]}"
68
+ return " ".join(words[:3])
69
+ return f"{words[0]} {words[1]}"
70
+ return words[0]
39
71
 
40
72
 
41
- # `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
42
- NOT_ASKED = (
43
- "This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
44
- "if you can; otherwise finish and say what permission you need."
45
- )
46
- LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
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 is_read_only(command: str) -> bool:
50
- command = LEADING_CD.sub("", command)
51
- if any(tok in command for tok in (";", "&", ">", "`", "$(", "<(")) or "\n" in command:
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 re.search(r"-exec|-delete|-ok\b|-fprint", command):
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
- for segment in command.split("|"):
56
- try:
57
- words = shlex.split(segment)
58
- except ValueError:
59
- return False
60
- if not words:
61
- return False
62
- if words[0] == "git":
63
- if len(words) < 2 or words[1] not in SAFE_GIT:
64
- return False
65
- elif words[0] not in SAFE_COMMANDS:
66
- return False
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
- # Bare JSON objects such as {"name": "grep", "arguments": {...}}, possibly inside ``` fences.
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
- for m in re.finditer(r"\{", content):
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, _ = decoder.raw_decode(content, m.start())
427
+ obj, position = decoder.raw_decode(body, position)
370
428
  except json.JSONDecodeError:
371
- continue
372
- if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
373
- calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
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
- out = "\n".join(f"{i:6}\t{line[:2000]}" for i, line in enumerate(chunk, start=offset))
643
- end = offset - 1 + len(chunk)
644
- if end < len(lines) or offset > 1:
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
- return p.read_text(errors="replace")
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.read_text(errors="replace") != content:
742
- p.write_text(content) # the file on disk must match, for checkpoints and the tools
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
- body = self._diff(p, self.read_text(p), content)
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
- lines: queue.Queue[str | None] = queue.Queue()
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
- lines.put(line)
888
- lines.put(None)
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: list[str] = []
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
- out.append(line)
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 out and out[-1] == "\n":
939
- out.pop() # the blank line printed before the marker
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(out[-40:])):
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 shorten("".join(out), MAX_TOOL_OUTPUT, "output") + status + f"\n[exit code: {code}]"
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.0
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=OG44SXHF0F_qxUeC9BH5G84cellTu3wqLwxsfUfYdUU,473
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=HFdmGXK_3oHu57L15z2dmJsQTj_ddjRDq7JnxM1Qq34,63045
6
- lcode/api.py,sha256=kYq7RghkNyf-A7qsZh6AeMzLoZbc9rTobsFRLtAcvD8,15480
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=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
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=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
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=Et8Tf4COLJ9_FsXtjcOciah_33lLgUS5sl4QAer1m8Y,50652
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=OFltvnRZlSUMtzNLEUF84RFoyKQkAFpb7_ZBVhVcVp4,48426
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.0.dist-info/METADATA,sha256=b-CcZWIZTz_P0M5H9xP1cwtQpICZpHlaGWdBRlSB52U,12620
62
- lcode_cli-0.28.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
63
- lcode_cli-0.28.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
64
- lcode_cli-0.28.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
65
- lcode_cli-0.28.0.dist-info/RECORD,,
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,,