lcode-cli 0.29.0__py3-none-any.whl → 0.31.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.29.0"
6
+ __version__ = "0.31.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/acp.py CHANGED
@@ -44,7 +44,7 @@ MODES = [
44
44
  ]
45
45
  KINDS = {
46
46
  "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
- "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
47
+ "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "apply_patch": "edit",
48
48
  "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
49
  "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
50
  } # fmt: skip
lcode/agent.py CHANGED
@@ -112,7 +112,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
112
112
  - To understand a repository: look at the layout, README/docs, entry points and config, then grep and read the relevant files. Cite code as `path:line`.
113
113
  - Read a file before editing it. Use edit_file for targeted changes and write_file for new files or full rewrites. old_string must be copied exactly from the file (without the line-number prefixes).
114
114
  - Match the existing code style, naming and structure. Don't add unrequested features.
115
- - For multi-step tasks, plan with todo_write and keep it updated.
115
+ - For multi-step tasks, plan with todo_write and keep it updated; put decisions and findings you must not lose in its notes.
116
116
  - Work autonomously until the task is done. Only stop to ask the user when you are genuinely blocked or the decision is theirs.
117
117
  - Don't run destructive or irreversible commands (rm -rf, git reset --hard, git push --force, dropping data) unless the user explicitly asked.
118
118
  - If the user denies a tool call, don't retry the same thing; adjust or ask.
@@ -613,6 +613,9 @@ class Agent:
613
613
  "title": self.session_title,
614
614
  "messages": self.messages,
615
615
  "checkpoints": self.checkpoints.to_json(),
616
+ "todos": self.tools.todos,
617
+ "notes": self.tools.notes,
618
+ "changed": list(self.tools.changed),
616
619
  }
617
620
  # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
618
621
  partial = f.with_name(f".{f.name}.partial")
@@ -626,6 +629,8 @@ class Agent:
626
629
  self.session_name = ""
627
630
  self.session_title = ""
628
631
  self.checkpoints.reset(self.session_id)
632
+ self.tools.todos, self.tools.notes, self.tools.changed = [], [], {}
633
+ self.check_results = []
629
634
 
630
635
  def rename(self, name: str) -> None:
631
636
  self.session_name = " ".join(name.split())
@@ -652,6 +657,9 @@ class Agent:
652
657
  self.session_name = data.get("name", "")
653
658
  self.session_title = data.get("title") or sessions.title_from(self.messages)
654
659
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
660
+ self.tools.todos = list(data.get("todos") or [])
661
+ self.tools.notes = [str(n) for n in data.get("notes") or []]
662
+ self.tools.changed = dict.fromkeys(str(f) for f in data.get("changed") or [])
655
663
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
656
664
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
657
665
  self.counted_chars = None
@@ -1134,7 +1142,7 @@ class Agent:
1134
1142
  )
1135
1143
  if verdict.stop:
1136
1144
  looping = self.describe_call(name, args)
1137
- if name in ("edit_file", "write_file") and not result.startswith(REFUSED):
1145
+ if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
1138
1146
  changed, verified = True, False
1139
1147
  elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
1140
1148
  verified = True # the model ran the checks itself, after its last change
@@ -1289,6 +1297,8 @@ class Agent:
1289
1297
  self.messages, keep=1, removed=removed
1290
1298
  ) # not enough: before the last request
1291
1299
  context_tools.note_removed(self.messages, removed)
1300
+ if saved:
1301
+ context_tools.pin(self.messages, context_tools.task_sheet(self))
1292
1302
  if saved:
1293
1303
  self.sent_chars = 0 # the conversation changed: the model reads it again
1294
1304
  self.pruned += 1
@@ -1333,7 +1343,9 @@ class Agent:
1333
1343
  {"role": "system", "content": self.system_prompt()},
1334
1344
  {
1335
1345
  "role": "user",
1336
- "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1346
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}"
1347
+ + context_tools.requests_block(requests)
1348
+ + (f"\n\n{sheet}" if (sheet := context_tools.task_sheet(self)) else ""),
1337
1349
  },
1338
1350
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1339
1351
  ]
lcode/bench.py CHANGED
@@ -789,6 +789,7 @@ class TaskResult:
789
789
  steps: int = 0 # model responses
790
790
  tool_errors: int = 0 # failed tool calls, including malformed ones
791
791
  error: str | None = None # why the run stopped early: a timeout or an Ollama error
792
+ errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
792
793
 
793
794
 
794
795
  @dataclass
@@ -861,14 +862,20 @@ class Elapsed:
861
862
  return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
862
863
 
863
864
 
865
+ def errors_by_tool(messages: list[dict]) -> dict[str, int]:
866
+ """Failed tool calls by tool, and "malformed" for calls the model wrote unreadably."""
867
+ counts: dict[str, int] = {}
868
+ for m in messages:
869
+ if m.get("role") == "tool" and m.get("content", "").startswith("Error:"):
870
+ name = m.get("tool_name") or "?"
871
+ counts[name] = counts.get(name, 0) + 1
872
+ elif m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call"):
873
+ counts["malformed"] = counts.get("malformed", 0) + 1
874
+ return counts
875
+
876
+
864
877
  def count_tool_errors(messages: list[dict]) -> int:
865
- failed = sum(1 for m in messages if m.get("role") == "tool" and m.get("content", "").startswith("Error:"))
866
- malformed = sum(
867
- 1
868
- for m in messages
869
- if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
870
- )
871
- return failed + malformed
878
+ return sum(errors_by_tool(messages).values())
872
879
 
873
880
 
874
881
  def run_task(
@@ -933,6 +940,7 @@ def run_task(
933
940
  detail=error or check.detail,
934
941
  steps=agent.usage["requests"] - before.get("requests", 0),
935
942
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
+ errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
936
944
  error=error,
937
945
  )
938
946
 
lcode/context.py CHANGED
@@ -49,7 +49,9 @@ REQUESTS_HEADER = "[The user's requests in this conversation, word for word, old
49
49
  MAX_REQUESTS = 8 # the most recent ones are kept through a summary
50
50
  MAX_REQUEST_CHARS = 1500
51
51
  # What lcode appends to the user's words: attached files, images and folders, and its own notes.
52
- ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
52
+ ATTACHED = re.compile(
53
+ r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )"
54
+ ) # also the sheet
53
55
 
54
56
 
55
57
  def request_text(content: str) -> str:
@@ -187,6 +189,51 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[
187
189
  return saved
188
190
 
189
191
 
192
+ SHEET_HEADER = "[lcode] Task sheet: the plan, notes and changes so far, kept when old messages are removed"
193
+ MAX_SHEET = 3000 # characters
194
+
195
+
196
+ def task_sheet(agent: Agent) -> str:
197
+ """The state of the work, from lcode's own records: the todo list and the model's notes (todo_write), the
198
+ files changed in the session and the last check. Pinned where old messages go: in summaries and next to the
199
+ request when tool output is removed."""
200
+ tools = agent.tools
201
+ parts = []
202
+ if tools.todos:
203
+ marks = {"completed": "[x]", "in_progress": "[>]", "pending": "[ ]"}
204
+ plan = "\n".join(f"{marks.get(t.get('status', ''), '[ ]')} {t.get('content', '')}" for t in tools.todos)
205
+ parts.append(f"Plan:\n{plan}")
206
+ if tools.notes:
207
+ parts.append("Notes:\n" + "\n".join(f"- {note}" for note in tools.notes))
208
+ if tools.changed:
209
+ files = list(tools.changed)
210
+ more = f" and {len(files) - 30} more" if len(files) > 30 else ""
211
+ parts.append("Files changed in this session: " + ", ".join(files[-30:]) + more)
212
+ if agent.check_results:
213
+ last = agent.check_results[-1]
214
+ code = last.get("exit_code")
215
+ state = "passed" if code == 0 else "didn't run" if code is None else f"failed (exit code {code})"
216
+ parts.append(f"Last check: `{last.get('command')}` {state}")
217
+ if not parts:
218
+ return ""
219
+ sheet = "\n\n".join(parts)
220
+ if len(sheet) > MAX_SHEET:
221
+ sheet = sheet[: MAX_SHEET - 20] + "\n[…cut]"
222
+ return f"{SHEET_HEADER}\n{sheet}"
223
+
224
+
225
+ def pin(messages: list[dict], text: str) -> None:
226
+ """Put a note next to the request the model is working on, replacing an earlier sheet there."""
227
+ if not text:
228
+ return
229
+ for message in reversed(messages):
230
+ content = str(message.get("content", ""))
231
+ if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
232
+ content = content.split("\n\n" + SHEET_HEADER, 1)[0]
233
+ message["content"] = f"{content}\n\n{text}"
234
+ return
235
+
236
+
190
237
  def note_removed(messages: list[dict], removed: list[str]) -> None:
191
238
  """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
192
239
  conversation is easy to overlook, and models then answer from memory instead of reading again."""
lcode/matching.py ADDED
@@ -0,0 +1,287 @@
1
+ """Find where an edit goes when the model's text doesn't match the file exactly.
2
+
3
+ Small models copy text imperfectly: with the line numbers `read_file` shows, with spaces where the file has
4
+ tabs, a block at the wrong indentation, quotes still escaped, a blank line dropped, a letter wrong. An exact
5
+ match fails and the model often repeats the same failing edit. So when the exact text isn't there, lcode tries,
6
+ in order: removing line-number prefixes and stray escapes; comparing lines without trailing whitespace, without
7
+ indentation (and re-indenting the new text to fit), without blank lines; and, last, a very close match. It
8
+ only ever applies a match that is unique; anything ambiguous or weak is refused, with the closest lines and
9
+ their numbers, so the model can copy them exactly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from difflib import SequenceMatcher
17
+
18
+ LINE_NUMBER = re.compile(r"^\s*\d+(?:\t|→| \| |: )") # how read_file and other tools number lines
19
+ CLOSE_ENOUGH = 0.9 # similarity a loose match needs
20
+ CLEAR_LEAD = 0.05 # how much better than the next one it must be
21
+ MAX_SIMILARITY_LINES = 20_000 # bigger files: no similarity search
22
+ HINT_FLOOR = 0.5 # weaker candidates aren't worth showing
23
+
24
+
25
+ @dataclass
26
+ class Found:
27
+ text: str # the file with the edit applied
28
+ how: str # how the match was found, for the model ("ignoring indentation"); "" for an exact match
29
+ start_line: int # first matched line, 1-based
30
+
31
+
32
+ @dataclass
33
+ class Miss:
34
+ reason: str # "not found" or "ambiguous"
35
+ hint: str # the closest lines, numbered, or ""
36
+
37
+
38
+ def numbered(lines: list[str], start: int) -> str:
39
+ return "\n".join(f"{start + i:6}\t{line.rstrip(chr(10))}" for i, line in enumerate(lines))
40
+
41
+
42
+ def strip_line_numbers(text: str) -> str | None:
43
+ lines = text.split("\n")
44
+ content = [line for line in lines if line.strip()]
45
+ if not content or not all(LINE_NUMBER.match(line) for line in content):
46
+ return None
47
+ return "\n".join(LINE_NUMBER.sub("", line, count=1) if line.strip() else line for line in lines)
48
+
49
+
50
+ def unescape(text: str, file_text: str) -> str | None:
51
+ """`\\"`, `\\n` and `\\t` written literally, when the file doesn't contain them literally."""
52
+ changed = text
53
+ for literal, real in (('\\"', '"'), ("\\'", "'"), ("\\t", "\t")):
54
+ if literal in changed and literal not in file_text:
55
+ changed = changed.replace(literal, real)
56
+ if "\\n" in changed and "\n" not in changed and "\\n" not in file_text:
57
+ changed = changed.replace("\\n", "\n")
58
+ return changed if changed != text else None
59
+
60
+
61
+ def indent_of(line: str) -> str:
62
+ return line[: len(line) - len(line.lstrip(" \t"))]
63
+
64
+
65
+ def width(indent: str) -> int:
66
+ return len(indent.expandtabs(4))
67
+
68
+
69
+ def reindent(new: str, old_lines: list[str], file_lines: list[str]) -> str:
70
+ """Move `new` from the old text's indentation to the file's, keeping its relative structure."""
71
+ old_first = next((line for line in old_lines if line.strip()), "")
72
+ file_first = next((line for line in file_lines if line.strip()), "")
73
+ new_first = next((line for line in new.split("\n") if line.strip()), "")
74
+ base_old, base_file = width(indent_of(old_first)), indent_of(file_first)
75
+ same_kind = ("\t" in base_file) == ("\t" in indent_of(new_first))
76
+ if base_old != width(base_file) and width(indent_of(new_first)) == width(base_file) and same_kind:
77
+ return new # the model got the old text's indentation wrong but wrote the new text in the file's
78
+ old_steps = sorted({width(indent_of(line)) - base_old for line in old_lines if line.strip()} - {0})
79
+ step_old = next((s for s in old_steps if s > 0), 4)
80
+ tabs = "\t" in base_file or any(indent_of(line).startswith("\t") for line in file_lines if line.strip())
81
+ file_steps = sorted({width(indent_of(line)) - width(base_file) for line in file_lines if line.strip()} - {0})
82
+ unit = "\t" if tabs else " " * next((s for s in file_steps if s > 0), step_old)
83
+ out = []
84
+ for line in new.split("\n"):
85
+ if not line.strip():
86
+ out.append(line)
87
+ continue
88
+ levels = round((width(indent_of(line)) - base_old) / step_old)
89
+ lead = base_file + unit * levels if levels >= 0 else base_file[: max(0, len(base_file) + levels * len(unit))]
90
+ out.append(lead + line.lstrip(" \t"))
91
+ return "\n".join(out)
92
+
93
+
94
+ def splice(lines: list[str], start: int, end: int, new: str) -> str:
95
+ """Replace lines[start:end] with `new`, keeping the line break the region ended with."""
96
+ ending = "\n" if lines[end - 1].endswith("\n") else ""
97
+ body = new.rstrip("\n")
98
+ return "".join(lines[:start]) + (body + ending if body else "") + "".join(lines[end:])
99
+
100
+
101
+ def line_matches(file_lines: list[str], old_lines: list[str], key) -> list[int]:
102
+ n = len(old_lines)
103
+ wanted = [key(line) for line in old_lines]
104
+ return [i for i in range(len(file_lines) - n + 1) if [key(line) for line in file_lines[i : i + n]] == wanted]
105
+
106
+
107
+ def locate(text: str, old: str, new: str) -> Found | Miss:
108
+ """Where `old` goes in `text`, when it isn't there exactly; with `new` in its place."""
109
+ # 1. Copying mistakes that leave the text itself right.
110
+ for fix, how in ((strip_line_numbers, "without the line numbers"), (unescape, "with the escapes undone")):
111
+ fixed = fix(old) if fix is strip_line_numbers else unescape(old, text)
112
+ if fixed is None:
113
+ continue
114
+ fixed_new = (fix(new) if fix is strip_line_numbers else unescape(new, text)) or new
115
+ if text.count(fixed) == 1:
116
+ start = text.index(fixed)
117
+ return Found(text.replace(fixed, fixed_new, 1), how, text.count("\n", 0, start) + 1)
118
+ old, new = fixed, fixed_new # the looser steps below work on the fixed text
119
+ file_lines = text.splitlines(keepends=True)
120
+ old_lines = old.strip("\n").split("\n")
121
+ if not old_lines or not any(line.strip() for line in old_lines):
122
+ return Miss("not found", "")
123
+ plain = [line.rstrip("\r\n") for line in file_lines]
124
+ # 2. Line by line, looser each time.
125
+ for key, how, indent in (
126
+ (lambda s: s.rstrip(), "ignoring trailing spaces", False),
127
+ (lambda s: s.strip(), "ignoring indentation", True),
128
+ ):
129
+ hits = line_matches(plain, old_lines, key)
130
+ if len(hits) == 1:
131
+ i = hits[0]
132
+ region = plain[i : i + len(old_lines)]
133
+ body = reindent(new.strip("\n"), old_lines, region) if indent else new.strip("\n")
134
+ return Found(splice(file_lines, i, i + len(old_lines), body), how, i + 1)
135
+ if len(hits) > 1:
136
+ return Miss("ambiguous", "\n\n".join(numbered(plain[i : i + len(old_lines)], i + 1) for i in hits[:3]))
137
+ # Without blank lines: map the non-blank lines back to the file.
138
+ solid = [line for line in old_lines if line.strip()]
139
+ content = [(i, line) for i, line in enumerate(plain) if line.strip()]
140
+ keys = [line.strip() for _, line in content]
141
+ wanted = [line.strip() for line in solid]
142
+ hits = [j for j in range(len(keys) - len(wanted) + 1) if keys[j : j + len(wanted)] == wanted]
143
+ first, last = (content[hits[0]][0], content[hits[0] + len(wanted) - 1][0]) if len(hits) == 1 else (0, -1)
144
+ if len(hits) == 1 and last - first + 1 != len(old_lines):
145
+ body = reindent(new.strip("\n"), old_lines, plain[first : last + 1])
146
+ return Found(splice(file_lines, first, last + 1, body), "ignoring blank lines", first + 1)
147
+ # 3. A very close match of the same number of lines, clearly better than any other. Only the lines the model
148
+ # changed change: the rest stays as the file has it now, so an earlier edit the model's copy doesn't show
149
+ # survives. Where a changed line differs in the file by more than a typo, the edit is refused.
150
+ if len(plain) > MAX_SIMILARITY_LINES:
151
+ return Miss("not found", "")
152
+ n = len(old_lines)
153
+ target = "\n".join(line.strip() for line in old_lines)
154
+ scores = []
155
+ for j in range(len(plain) - n + 1):
156
+ if (old_lines[0].strip() and not plain[j].strip()) or (old_lines[-1].strip() and not plain[j + n - 1].strip()):
157
+ continue # a window that starts or ends on a blank line where the model's text doesn't
158
+ window = "\n".join(line.strip() for line in plain[j : j + n])
159
+ matcher = SequenceMatcher(None, target, window, autojunk=False)
160
+ if matcher.real_quick_ratio() < HINT_FLOOR or matcher.quick_ratio() < HINT_FLOOR:
161
+ continue
162
+ scores.append((matcher.ratio(), j))
163
+ scores.sort(reverse=True)
164
+ if not scores or scores[0][0] < HINT_FLOOR:
165
+ return Miss("not found", "")
166
+ best, j = scores[0]
167
+ runner_up = scores[1][0] if len(scores) > 1 else 0.0
168
+ if best >= CLOSE_ENOUGH and best - runner_up >= CLEAR_LEAD:
169
+ merged = merge(old_lines, new.strip("\n").split("\n"), plain[j : j + n])
170
+ if merged is not None:
171
+ body = "\n".join(merged)
172
+ return Found(splice(file_lines, j, j + n, body), f"at a close match ({best:.0%} alike)", j + 1)
173
+ # To show the model where to look, compare line by line: one long line doesn't outweigh the rest.
174
+ hint = max(scores[:20], key=lambda score: line_by_line(old_lines, plain[score[1] : score[1] + n]))[1]
175
+ return Miss("not found", numbered(plain[hint : hint + n], hint + 1))
176
+
177
+
178
+ def line_by_line(old: list[str], region: list[str]) -> float:
179
+ pairs = list(zip(old, region, strict=False)) # the region may end early, at the end of the file
180
+ return sum(SequenceMatcher(None, a.strip(), b.strip(), autojunk=False).ratio() for a, b in pairs) / len(pairs)
181
+
182
+
183
+ def merge(old: list[str], new: list[str], region: list[str]) -> list[str] | None:
184
+ """Apply the model's change (old -> new) to the file's lines (`region`, aligned with `old`). None when a line
185
+ it changes differs in the file by more than a typo: the model's copy is out of date there."""
186
+ new = reindent("\n".join(new), old, region).split("\n")
187
+ out: list[str] = []
188
+ ops = SequenceMatcher(None, [line.strip() for line in old], [line.strip() for line in new], autojunk=False)
189
+ for tag, i1, i2, j1, j2 in ops.get_opcodes():
190
+ if tag == "equal":
191
+ out += region[i1:i2] # as the file has them
192
+ continue
193
+ for k in range(i1, i2):
194
+ alike = SequenceMatcher(None, old[k].strip(), region[k].strip(), autojunk=False).ratio()
195
+ if old[k].strip() != region[k].strip() and alike < CLOSE_ENOUGH:
196
+ return None
197
+ out += new[j1:j2]
198
+ return out
199
+
200
+
201
+ # ----------------------------------------------------------------------------- unified diffs
202
+
203
+
204
+ @dataclass
205
+ class Hunk:
206
+ header: str # the @@ line, for messages
207
+ old: str # its context and removed lines
208
+ new: str # its context and added lines
209
+
210
+
211
+ @dataclass
212
+ class FilePatch:
213
+ path: str # without a/ and b/
214
+ hunks: list[Hunk]
215
+ new_file: bool = False
216
+ deleted: bool = False
217
+
218
+
219
+ def strip_prefix(path: str) -> str:
220
+ path = path.strip().split("\t")[0]
221
+ return path[2:] if path[:2] in ("a/", "b/") else path
222
+
223
+
224
+ def parse_patch(patch: str) -> list[FilePatch]:
225
+ """A unified diff (as git diff writes it) as files and hunks. The @@ line numbers aren't trusted: hunks are
226
+ found by their text. Raises ValueError when it isn't a diff."""
227
+ files: list[FilePatch] = []
228
+ hunk: list[str] | None = None
229
+ current: FilePatch | None = None
230
+
231
+ def close() -> None:
232
+ nonlocal hunk
233
+ if hunk is not None and current is not None:
234
+ old = [line[1:] for line in hunk[1:] if line[:1] in (" ", "-")]
235
+ new = [line[1:] for line in hunk[1:] if line[:1] in (" ", "+")]
236
+ current.hunks.append(Hunk(hunk[0], "\n".join(old), "\n".join(new)))
237
+ hunk = None
238
+
239
+ lines = patch.replace("\r\n", "\n").split("\n")
240
+ i = 0
241
+ while i < len(lines):
242
+ line = lines[i]
243
+ if line.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
244
+ close()
245
+ old_path, new_path = line[4:], lines[i + 1][4:]
246
+ gone, born = old_path.strip() == "/dev/null", new_path.strip() == "/dev/null"
247
+ current = FilePatch(strip_prefix(old_path if born else new_path), [], new_file=gone, deleted=born)
248
+ files.append(current)
249
+ i += 2
250
+ continue
251
+ if line.startswith("@@") and current is not None:
252
+ close()
253
+ hunk = [line]
254
+ elif hunk is not None:
255
+ if line.startswith(("\\ No newline", "diff --git", "index ")):
256
+ pass
257
+ elif line == "" and i == len(lines) - 1:
258
+ pass # the patch's own final newline
259
+ else:
260
+ hunk.append(line if line[:1] in (" ", "-", "+") else " " + line) # a context line that lost its space
261
+ i += 1
262
+ close()
263
+ if not files or not any(f.hunks for f in files):
264
+ raise ValueError("no ---/+++ file headers with @@ hunks: write a unified diff, as git diff does")
265
+ return files
266
+
267
+
268
+ def apply_hunks(text: str, hunks: list[Hunk]) -> tuple[str, list[str]]:
269
+ """Apply hunks one after another; returns the text and how each loose match was made. Raises ValueError
270
+ naming the hunk that can't be placed."""
271
+ notes = []
272
+ for n, hunk in enumerate(hunks, 1):
273
+ if not hunk.old.strip():
274
+ raise ValueError(f"hunk {n} ({hunk.header}) has no context lines to place it by")
275
+ count = text.count(hunk.old)
276
+ if count == 1:
277
+ text = text.replace(hunk.old, hunk.new, 1)
278
+ continue
279
+ if count > 1:
280
+ raise ValueError(f"hunk {n} ({hunk.header}) matches {count} places: add more context lines")
281
+ found = locate(text, hunk.old, hunk.new)
282
+ if isinstance(found, Miss):
283
+ where = f" The closest text is:\n{found.hint}" if found.hint else ""
284
+ raise ValueError(f"hunk {n} ({hunk.header}) doesn't match the file.{where}")
285
+ text = found.text
286
+ notes.append(f"hunk {n} {found.how}")
287
+ return text, notes
lcode/repeats.py CHANGED
@@ -82,7 +82,7 @@ class Repeats:
82
82
  )
83
83
  hint = (
84
84
  "If it's an edit, read the file again and copy the text exactly. "
85
- if failed and name in ("edit_file", "write_file")
85
+ if failed and name in ("edit_file", "write_file", "apply_patch")
86
86
  else ""
87
87
  )
88
88
  return Verdict(
@@ -97,7 +97,7 @@ def changes_things(name: str, args: dict, result: str) -> bool:
97
97
  """Whether the call may have changed files (or anything else a later call could see)."""
98
98
  if name in UNCHANGING:
99
99
  return False
100
- if name in ("edit_file", "write_file"):
100
+ if name in ("edit_file", "write_file", "apply_patch"):
101
101
  return not result.startswith(FAILED)
102
102
  if name == "bash":
103
103
  return not is_read_only(str(args.get("command", "")))
lcode/repl.py CHANGED
@@ -63,6 +63,7 @@ COMMANDS = {
63
63
  "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
64
64
  "/cd": "Change the working directory",
65
65
  "/todos": "Show the current todo list",
66
+ "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
66
67
  "/jobs": "Background commands the model started: /jobs, /jobs stop <id>",
67
68
  "/commit": "Commit the changes with a message in the repository's style, after you approve it",
68
69
  "/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
@@ -109,6 +110,31 @@ def mention(path: Path) -> str:
109
110
  return f'@"{shown}"' if " " in shown else f"@{shown}"
110
111
 
111
112
 
113
+ def status_command(agent: Agent) -> None:
114
+ """What the session is doing, as lcode keeps it for the model (the task sheet)."""
115
+ c = agent.console
116
+ requests = context_tools.user_requests(agent.messages)
117
+ if not requests and not agent.tools.todos and not agent.tools.changed:
118
+ c.print("Nothing yet: no requests in this session.")
119
+ return
120
+ if requests:
121
+ latest = requests[-3:]
122
+ lines = [f"[dim]{len(requests) - len(latest) + i + 1}.[/] {escape(r[:300])}" for i, r in enumerate(latest)]
123
+ c.print(Panel("\n".join(lines), title="Requests (latest)", title_align="left", border_style="cyan"))
124
+ if agent.tools.todos or agent.tools.notes:
125
+ agent.tools.show_todos()
126
+ if agent.tools.changed:
127
+ c.print(f"[bold]Files changed:[/] {escape(', '.join(agent.tools.changed))}")
128
+ if agent.check_results:
129
+ last = agent.check_results[-1]
130
+ code = last.get("exit_code")
131
+ state = (
132
+ "[green]passed[/]" if code == 0 else "didn't run" if code is None else f"[red]failed[/] (exit code {code})"
133
+ )
134
+ c.print(f"[bold]Last check:[/] {escape(str(last.get('command')))} {state}")
135
+ c.print("[dim]The model keeps this too, even when old messages are removed or summarized.[/]")
136
+
137
+
112
138
  def build_session(agent: Agent) -> PromptSession:
113
139
  kb = KeyBindings()
114
140
 
@@ -978,6 +1004,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
978
1004
  c.print(f"Working directory: {p}")
979
1005
  else:
980
1006
  c.print(f"[red]Not a directory: {p}[/]")
1007
+ elif cmd == "/status":
1008
+ status_command(agent)
981
1009
  elif cmd == "/todos":
982
1010
  agent.tools.show_todos()
983
1011
  elif cmd == "/jobs":
lcode/subagents.py CHANGED
@@ -69,7 +69,7 @@ READ_ONLY_TOOLS = frozenset(
69
69
  "search_code",
70
70
  }
71
71
  )
72
- EDIT_TOOLS = frozenset({"write_file", "edit_file"})
72
+ EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
73
73
  NOT_FOR_SUBAGENTS = frozenset({"agent", "memory"}) # no agents inside agents; only the main session remembers
74
74
  MAX_STEPS = 200
75
75
  MAX_UNTRACKED_BYTES = 10 * 1024 * 1024 # larger untracked files aren't copied into a worker's worktree
lcode/tools.py CHANGED
@@ -24,11 +24,12 @@ from pathlib import Path
24
24
  from typing import TYPE_CHECKING
25
25
 
26
26
  from rich.console import Group
27
+ from rich.markup import escape
27
28
  from rich.panel import Panel
28
29
  from rich.syntax import Syntax
29
30
  from rich.text import Text
30
31
 
31
- from lcode import secretscan, web
32
+ from lcode import matching, secretscan, web
32
33
  from lcode.context import OutputSpool, shorten
33
34
  from lcode.jobs import JobError, describe, stop_leftovers
34
35
  from lcode.permissions import bash_key, is_read_only
@@ -45,6 +46,7 @@ NO_NETWORK = re.compile(
45
46
  )
46
47
  MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
47
48
  PENDING_LINES = 5000 # output lines waiting to be read, at most
49
+ MAX_NOTES, MAX_NOTE = 12, 300 # the model's task notes: how many, how long each
48
50
  LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running
49
51
  IGNORE_DIRS = {
50
52
  ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
@@ -96,6 +98,15 @@ SCHEMAS = [
96
98
  },
97
99
  ["path", "old_string", "new_string"],
98
100
  ),
101
+ _fn(
102
+ "apply_patch",
103
+ "Apply a unified diff to one or more files, as `git diff` writes it: `--- a/path` and `+++ b/path` "
104
+ "headers, then `@@` hunks of context lines (starting with a space), removed lines (-) and added lines (+). "
105
+ "Hunks are found by their text, so the @@ line numbers may be off; include 2-3 context lines around each "
106
+ "change. Use `--- /dev/null` for a new file. For a single change, edit_file is simpler.",
107
+ {"patch": {"type": "string", "description": "The unified diff"}},
108
+ ["patch"],
109
+ ),
99
110
  _fn(
100
111
  "list_dir",
101
112
  "Show a directory tree (skips .git, node_modules, virtualenvs and caches).",
@@ -152,7 +163,8 @@ SCHEMAS = [
152
163
  _fn(
153
164
  "todo_write",
154
165
  "Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
155
- "item in_progress while working.",
166
+ "item in_progress while working. In `notes`, keep what you must not forget: decisions and why, findings, "
167
+ "open questions. The list and the notes stay with you even when old messages are removed.",
156
168
  {
157
169
  "todos": {
158
170
  "type": "array",
@@ -164,7 +176,12 @@ SCHEMAS = [
164
176
  },
165
177
  "required": ["content", "status"],
166
178
  },
167
- }
179
+ },
180
+ "notes": {
181
+ "type": "array",
182
+ "items": {"type": "string"},
183
+ "description": "Short notes, the full list each time (omit to keep the current ones)",
184
+ },
168
185
  },
169
186
  ["todos"],
170
187
  ),
@@ -375,22 +392,6 @@ def tree(base: Path, depth: int = 2, limit: int = 400) -> str:
375
392
  return "\n".join(lines)
376
393
 
377
394
 
378
- def fuzzy_replace(text: str, old: str, new: str) -> str | None:
379
- """Replace `old` with `new` matching lines while ignoring trailing whitespace. None unless exactly one match."""
380
- t_lines = text.splitlines(keepends=True)
381
- o_lines = [line.rstrip() for line in old.strip("\n").splitlines()]
382
- if not o_lines:
383
- return None
384
- n = len(o_lines)
385
- matches = [i for i in range(len(t_lines) - n + 1) if [ln.rstrip() for ln in t_lines[i : i + n]] == o_lines]
386
- if len(matches) != 1:
387
- return None
388
- i = matches[0]
389
- tail = "\n" if t_lines[i + n - 1].endswith("\n") else ""
390
- replacement = new.strip("\n") + tail if new.strip("\n") else ""
391
- return "".join(t_lines[:i]) + replacement + "".join(t_lines[i + n :])
392
-
393
-
394
395
  def parse_text_tool_calls(content: str) -> list[dict]:
395
396
  """Recover tool calls that a model printed as text instead of emitting structured calls."""
396
397
  calls = []
@@ -445,6 +446,8 @@ class Toolbox:
445
446
  self.agent = agent
446
447
  self.read_mtimes: dict[str, float] = {}
447
448
  self.formats: dict[str, TextFormat] = {} # encoding and line endings of the files read
449
+ self.notes: list[str] = [] # the model's notes on the task (todo_write): decisions, findings, questions
450
+ self.changed: dict[str, None] = {} # files lcode wrote in this session, in order
448
451
  self.todos: list[dict] = []
449
452
  # An editor's buffers (lcode acp): read unsaved changes and write through the editor.
450
453
  self.reader: Callable[[Path], str | None] | None = None
@@ -703,9 +706,10 @@ class Toolbox:
703
706
  def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
704
707
  p = self.path(path, read=True)
705
708
  if not p.exists():
706
- raise ToolError(f"{path} does not exist")
709
+ raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
707
710
  if p.is_dir():
708
- raise ToolError(f"{path} is a directory; use list_dir")
711
+ listing = tree(p, depth=1)
712
+ return f"{self.rel(p)} is a directory, so here's what's in it (read a file with read_file):\n{listing}"
709
713
  if is_image(p):
710
714
  raise ToolError(f"{path} is an image; look at it with view_image")
711
715
  if is_binary(p):
@@ -736,8 +740,10 @@ class Toolbox:
736
740
 
737
741
  def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
738
742
  p = self.path(path, read=True)
743
+ if p.is_file():
744
+ return f"{self.rel(p)} is a file, not a directory: read it with read_file."
739
745
  if not p.is_dir():
740
- raise ToolError(f"{path} is not a directory")
746
+ raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
741
747
  return tree(p, depth=max(1, min(int(depth or 2), 6)))
742
748
 
743
749
  def t_glob(self, pattern: str, path: str = ".") -> str:
@@ -749,6 +755,8 @@ class Toolbox:
749
755
  ]
750
756
  hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
751
757
  if not hits:
758
+ if not base.is_dir():
759
+ return f"No files found: {path} does not exist.{self._did_you_mean(path)}"
752
760
  return "No files found."
753
761
  more = f"\n... and {len(hits) - 200} more" if len(hits) > 200 else ""
754
762
  return "\n".join(self.rel(p) for p in hits[:200]) + more
@@ -757,6 +765,7 @@ class Toolbox:
757
765
  self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
758
766
  ) -> str:
759
767
  p = self.path(path)
768
+ literal = False # the pattern wasn't a valid regex, so it was searched as plain text
760
769
  if shutil.which("rg"):
761
770
  cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
762
771
  cmd += ["--max-columns", "400"]
@@ -768,16 +777,24 @@ class Toolbox:
768
777
  cmd += ["-g", glob]
769
778
  for d in IGNORE_DIRS:
770
779
  cmd += ["-g", f"!{d}/"]
771
- cmd += ["-e", pattern, str(p)]
772
- r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
780
+ if not p.exists():
781
+ raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
782
+ r = subprocess.run([*cmd, "-e", pattern, str(p)], capture_output=True, text=True, timeout=60)
783
+ if r.returncode == 2 and "regex parse error" in r.stderr:
784
+ # "def add(" is meant literally: search for the text instead of failing
785
+ r = subprocess.run([*cmd, "-F", "-e", pattern, str(p)], capture_output=True, text=True, timeout=60)
786
+ literal = True
773
787
  if r.returncode == 2:
774
788
  raise ToolError(r.stderr.strip())
775
789
  out = r.stdout
776
790
  else:
791
+ if not p.exists():
792
+ raise ToolError(f"{path} does not exist.{self._did_you_mean(path)}")
777
793
  try:
778
794
  rx = re.compile(pattern, re.I if ignore_case else 0)
779
- except re.error as e:
780
- raise ToolError(f"invalid regex: {e}") from e
795
+ except re.error:
796
+ rx = re.compile(re.escape(pattern), re.I if ignore_case else 0) # meant literally
797
+ literal = True
781
798
  results = []
782
799
  for f in [p] if p.is_file() else walk_files(p):
783
800
  if (glob and not fnmatch.fnmatch(f.name, glob)) or is_binary(f):
@@ -791,13 +808,38 @@ class Toolbox:
791
808
  out = "\n".join(results)
792
809
  out = out.replace(str(self.agent.cwd) + os.sep, "")
793
810
  lines = out.splitlines()
811
+ note = "[The pattern isn't a valid regular expression, so it was searched as plain text.]\n" if literal else ""
794
812
  if not lines:
795
- return "No matches."
813
+ return note + "No matches."
796
814
  if len(lines) > 400:
797
- return "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
798
- return out
815
+ return note + "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
816
+ return note + out
799
817
 
800
818
  # -- file changes
819
+ def _did_you_mean(self, path: str) -> str:
820
+ """Existing paths close to one that doesn't exist: the same name elsewhere in the project, or a near name
821
+ in the nearest folder that exists. Saves the model a search, or a guess."""
822
+ wanted = self.resolve(path)
823
+ found: list[Path] = []
824
+ from lcode.checkpoints import work_tree_for
825
+
826
+ for i, candidate in enumerate(walk_files(work_tree_for(self.agent.cwd))):
827
+ if i > 20_000 or len(found) >= 3:
828
+ break
829
+ if candidate.name == wanted.name:
830
+ found.append(candidate)
831
+ folder = wanted.parent
832
+ while not folder.is_dir() and folder != folder.parent:
833
+ folder = folder.parent
834
+ try:
835
+ names = [e.name + ("/" if e.is_dir() else "") for e in folder.iterdir() if e.name not in IGNORE_DIRS]
836
+ except OSError:
837
+ names = []
838
+ for name in difflib.get_close_matches(wanted.name, names, n=3, cutoff=0.6):
839
+ found.append(folder / name.rstrip("/"))
840
+ shown = list(dict.fromkeys(self.rel(p) + ("/" if p.is_dir() else "") for p in found))[:3]
841
+ return f" Did you mean: {', '.join(shown)}?" if shown else ""
842
+
801
843
  def _check_fresh(self, p: Path) -> None:
802
844
  key = str(p)
803
845
  if key not in self.read_mtimes:
@@ -833,6 +875,7 @@ class Toolbox:
833
875
  if not p.exists() or p.read_bytes() != data:
834
876
  p.write_bytes(data) # the file on disk must match, for checkpoints and the tools
835
877
  self.read_mtimes[str(p)] = p.stat().st_mtime
878
+ self.changed[self.rel(p)] = None
836
879
 
837
880
  def t_write_file(self, path: str, content: str) -> str:
838
881
  p = self.path(path)
@@ -873,6 +916,63 @@ class Toolbox:
873
916
  "on its current content."
874
917
  )
875
918
 
919
+ def t_apply_patch(self, patch: str) -> str:
920
+ try:
921
+ files = matching.parse_patch(patch)
922
+ except ValueError as e:
923
+ raise ToolError(str(e)) from e
924
+ planned: list[tuple[Path, str | None, str, list[str]]] = [] # path, before, after, notes
925
+ for fp in files:
926
+ p = self.path(fp.path)
927
+ if fp.deleted:
928
+ raise ToolError(f"{fp.path}: deleting files isn't something apply_patch does; use bash (rm)")
929
+ if fp.new_file:
930
+ if p.exists():
931
+ raise ToolError(f"{fp.path} already exists; the patch creates it from /dev/null")
932
+ planned.append((p, None, "\n".join(h.new for h in fp.hunks) + "\n", []))
933
+ continue
934
+ if not p.is_file():
935
+ raise ToolError(f"{fp.path} does not exist (use --- /dev/null to create it)")
936
+ self._check_fresh(p)
937
+ before = self.read_text(p)
938
+ try:
939
+ after, notes = matching.apply_hunks(before, fp.hunks)
940
+ except ValueError as e:
941
+ raise ToolError(f"{fp.path}: {e}") from e
942
+ planned.append((p, before, after, notes))
943
+ done = []
944
+ for p, before, after, notes in planned:
945
+ body = self._diff(p, before or "", after)
946
+ verb = "Create" if before is None else "Patch"
947
+ ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
948
+ if not ok:
949
+ return feedback + (f" (already patched: {', '.join(done)})" if done else "")
950
+ if (self.read_text(p) if p.exists() else None) != before:
951
+ return self._changed_meanwhile(p)
952
+ self.agent.checkpoint()
953
+ self._write(p, after)
954
+ self.console.print(f" [green]✓[/] {'Created' if before is None else 'Patched'} {self.rel(p)}")
955
+ done.append(self.rel(p) + (f" ({'; '.join(notes)})" if notes else ""))
956
+ problems = self._check(p, before, after)
957
+ if problems:
958
+ done[-1] += problems
959
+ return "Patched " + ", ".join(done) + "."
960
+
961
+ def _edit_miss(self, p: Path, miss: matching.Miss) -> str:
962
+ """Why an edit can't be placed, with the closest lines so the model can copy them exactly."""
963
+ if miss.reason == "ambiguous":
964
+ return (
965
+ f"old_string isn't in {self.rel(p)} exactly, and it's close to more than one place:\n{miss.hint}\n"
966
+ "Add surrounding lines to old_string so it matches only one, copied exactly."
967
+ )
968
+ message = f"old_string not found in {self.rel(p)}. It must match the file exactly, including indentation."
969
+ if miss.hint:
970
+ return (
971
+ message
972
+ + f" The closest text is:\n{miss.hint}\nCopy what you mean from it exactly (without the line numbers)."
973
+ )
974
+ return message + " Re-read the file and copy the text precisely (without line-number prefixes)."
975
+
876
976
  def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
877
977
  p = self.path(path)
878
978
  if not p.exists():
@@ -884,14 +984,12 @@ class Toolbox:
884
984
  raise ToolError("old_string is empty; use write_file to create or overwrite files")
885
985
  text = self.read_text(p)
886
986
  count = text.count(old_string)
987
+ how, start_line = "", 0
887
988
  if count == 0:
888
- new_text = fuzzy_replace(text, old_string, new_string)
889
- if new_text is None:
890
- raise ToolError(
891
- f"old_string not found in {self.rel(p)}. It must match exactly, including indentation. "
892
- "Re-read the file and copy the text precisely (without line-number prefixes)."
893
- )
894
- count = 1
989
+ found = matching.locate(text, old_string, new_string)
990
+ if isinstance(found, matching.Miss):
991
+ raise ToolError(self._edit_miss(p, found))
992
+ new_text, how, start_line, count = found.text, found.how, found.start_line, 1
895
993
  elif count > 1 and not replace_all:
896
994
  raise ToolError(
897
995
  f"old_string occurs {count} times in {self.rel(p)}. Add surrounding lines to make it unique, "
@@ -909,15 +1007,16 @@ class Toolbox:
909
1007
  self._write(p, new_text)
910
1008
  self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
911
1009
  problems = self._check(p, text, new_text)
912
- idx = new_text.find(new_string) if new_string else -1
913
- if idx < 0:
914
- return f"Edited {self.rel(p)} ({count} replacement(s))." + problems
1010
+ done = f"Edited {self.rel(p)} ({count} replacement(s)" + (f", {how}" if how else "") + ")."
1011
+ idx = new_text.find(new_string) if new_string and not how else -1
1012
+ if idx < 0 and not start_line:
1013
+ return done + problems
915
1014
  # Show the model the edited region so it can verify the result.
916
- start = new_text.count("\n", 0, idx) + 1
1015
+ start = new_text.count("\n", 0, idx) + 1 if idx >= 0 else start_line
917
1016
  lines = new_text.splitlines()
918
1017
  lo, hi = max(1, start - 3), min(len(lines), start + new_string.count("\n") + 3)
919
1018
  snippet = "\n".join(f"{i:6}\t{lines[i - 1]}" for i in range(lo, hi + 1))
920
- return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}" + problems
1019
+ return f"{done} Result:\n{snippet}" + problems
921
1020
 
922
1021
  # -- shell
923
1022
  def t_bash(self, command: str, timeout: int = 180, background: bool = False) -> str:
@@ -1160,12 +1259,29 @@ class Toolbox:
1160
1259
  return f"[{describe(job)}]" + (f"\nIts last output:\n{last}" if last else "")
1161
1260
 
1162
1261
  # -- planning
1163
- def t_todo_write(self, todos: list | str) -> str:
1262
+ def t_todo_write(self, todos: list | str, notes: list | str | None = None) -> str:
1164
1263
  if isinstance(todos, str):
1165
1264
  todos = json.loads(todos)
1166
1265
  self.todos = list(todos)
1266
+ # Small models often put a note inside an item instead of in `notes`: keep it either way.
1267
+ attached = [
1268
+ str(todo.get(key)).strip()
1269
+ for todo in self.todos
1270
+ if isinstance(todo, dict)
1271
+ for key in ("notes", "note", "details")
1272
+ if str(todo.get(key) or "").strip()
1273
+ ]
1274
+ if attached:
1275
+ notes = [*([] if notes is None else ([notes] if isinstance(notes, str) else list(notes))), *attached]
1276
+ if notes is not None:
1277
+ if isinstance(notes, str):
1278
+ try:
1279
+ notes = json.loads(notes)
1280
+ except ValueError:
1281
+ notes = [notes]
1282
+ self.notes = [str(n).strip()[:MAX_NOTE] for n in notes if str(n).strip()][:MAX_NOTES]
1167
1283
  self.show_todos()
1168
- return "Todo list updated."
1284
+ return "Todo list updated." + (f" {len(self.notes)} note(s) kept." if notes is not None else "")
1169
1285
 
1170
1286
  def show_todos(self) -> None:
1171
1287
  icons = {"completed": "[green]✔[/]", "in_progress": "[yellow]◐[/]", "pending": "[dim]○[/]"}
@@ -1177,4 +1293,6 @@ class Toolbox:
1177
1293
  elif status == "in_progress":
1178
1294
  text = f"[bold]{text}[/]"
1179
1295
  rows.append(f"{icons.get(status, '○')} {text}")
1296
+ if self.notes:
1297
+ rows += ["", *(f"[dim]•[/] {escape(note)}" for note in self.notes)]
1180
1298
  self.console.print(Panel("\n".join(rows) or "(empty)", title="Todos", title_align="left", border_style="blue"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.29.0
3
+ Version: 0.31.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,11 +1,11 @@
1
- lcode/__init__.py,sha256=MDtHYmkvKuvQap8m7LmA2apEsX16ubVEWst8qW4l_kQ,473
1
+ lcode/__init__.py,sha256=gXBLoiYa3VhO8oR-eyFbfplfXuE6GNMT6g92TwUc_2g,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
3
+ lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=mYGtftFoEzgI-KvpDpXTOLmtKPrB3ZVjfP__1B872r8,69547
5
+ lcode/agent.py,sha256=OkGViWlZd8g-MFZpaCnbRIpmrFXnKajbIeEMKV1e7-o,70275
6
6
  lcode/api.py,sha256=PhG8PZReM7DXSxG6wf7mEjAKx_TAsgj3xHApacrXGCM,15684
7
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
- lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
8
+ lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
10
10
  lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
@@ -15,7 +15,7 @@ lcode/cli.py,sha256=svnW6pfLtqiMXbA3wMlYESKGgGe6Xb9wlWkqg6Cz0O0,41081
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
17
  lcode/config.py,sha256=KSjSBvxA9nmd4h4QYk3xp8BQ7C04pjTULsSn0jcQwBc,11301
18
- lcode/context.py,sha256=WSMiitscDh2Juy4Hx0x9EPNWrn804IbOWRZly1-DU38,17022
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
21
21
  lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
@@ -24,6 +24,7 @@ lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
24
24
  lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
25
25
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
26
26
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
27
+ lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
27
28
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
28
29
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
29
30
  lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
@@ -31,15 +32,15 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
31
32
  lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
32
33
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
33
34
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
34
- lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
35
- lcode/repl.py,sha256=ayrKySMbbRbax_WMjqYBN4fkcDpzuHniefJC3DZ4-2Y,50687
35
+ lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
36
+ lcode/repl.py,sha256=0f3nojURlQE8Pm_JkXvh9y7nEg1CHG3xanZIs0osPxw,52107
36
37
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
37
38
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
38
39
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
39
40
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
40
41
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
41
- lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
42
- lcode/tools.py,sha256=h7C6r27_cBoCkW8tZ4BLmxdgRByYa2cUKh8AWfmEucI,54193
42
+ lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
43
+ lcode/tools.py,sha256=PofQ77Y7TvsUcFcvrUDP4GirpgkQKhhC_NL_1K_8_Jo,61182
43
44
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
44
45
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
45
46
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -59,8 +60,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
59
60
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
60
61
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
61
62
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
62
- lcode_cli-0.29.0.dist-info/METADATA,sha256=IYMbafynIQ-eNxr7iOJkmc5vXhfry3Q5FIeyMXmeEgE,12620
63
- lcode_cli-0.29.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
64
- lcode_cli-0.29.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
65
- lcode_cli-0.29.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
66
- lcode_cli-0.29.0.dist-info/RECORD,,
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,,