lcode-cli 0.30.0__py3-none-any.whl → 0.32.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.30.0"
6
+ __version__ = "0.32.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -35,6 +35,7 @@ from lcode import (
35
35
  planning,
36
36
  repomap,
37
37
  residency,
38
+ review,
38
39
  sessions,
39
40
  subagents,
40
41
  vision,
@@ -112,7 +113,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
112
113
  - 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
114
  - 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
115
  - 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.
116
+ - For multi-step tasks, plan with todo_write and keep it updated; put decisions and findings you must not lose in its notes.
116
117
  - Work autonomously until the task is done. Only stop to ask the user when you are genuinely blocked or the decision is theirs.
117
118
  - Don't run destructive or irreversible commands (rm -rf, git reset --hard, git push --force, dropping data) unless the user explicitly asked.
118
119
  - If the user denies a tool call, don't retry the same thing; adjust or ask.
@@ -276,6 +277,7 @@ class Settings:
276
277
  compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
277
278
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
278
279
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
280
+ review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
279
281
 
280
282
 
281
283
  class Agent:
@@ -334,6 +336,7 @@ class Agent:
334
336
  self.autosave = False # save the session after every step (sessions people resume; not the bench's)
335
337
  self._check: tuple[Path, checks.Check | None] | None = None
336
338
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
339
+ self.review_results: list[str] | None = None # what the last request's review found (None: no review)
337
340
  self.sent_chars = 0 # characters of the conversation the model had already read
338
341
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
339
342
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -613,6 +616,9 @@ class Agent:
613
616
  "title": self.session_title,
614
617
  "messages": self.messages,
615
618
  "checkpoints": self.checkpoints.to_json(),
619
+ "todos": self.tools.todos,
620
+ "notes": self.tools.notes,
621
+ "changed": list(self.tools.changed),
616
622
  }
617
623
  # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
618
624
  partial = f.with_name(f".{f.name}.partial")
@@ -626,6 +632,8 @@ class Agent:
626
632
  self.session_name = ""
627
633
  self.session_title = ""
628
634
  self.checkpoints.reset(self.session_id)
635
+ self.tools.todos, self.tools.notes, self.tools.changed = [], [], {}
636
+ self.check_results = []
629
637
 
630
638
  def rename(self, name: str) -> None:
631
639
  self.session_name = " ".join(name.split())
@@ -652,6 +660,9 @@ class Agent:
652
660
  self.session_name = data.get("name", "")
653
661
  self.session_title = data.get("title") or sessions.title_from(self.messages)
654
662
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
663
+ self.tools.todos = list(data.get("todos") or [])
664
+ self.tools.notes = [str(n) for n in data.get("notes") or []]
665
+ self.tools.changed = dict.fromkeys(str(f) for f in data.get("changed") or [])
655
666
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
656
667
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
657
668
  self.counted_chars = None
@@ -823,6 +834,48 @@ class Agent:
823
834
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
824
835
  return message
825
836
 
837
+ def reviewing(self) -> bool:
838
+ return (
839
+ self.settings.review != "off" and self.allowed_tools is None and not self.planning() and not self.no_changes
840
+ )
841
+
842
+ def self_review(self, answer: str) -> list[str]:
843
+ """Check the request's change against the request before the answer counts; the problems found."""
844
+ budget = int(self.settings.context * 3 * 0.25)
845
+ diff, changed = review.diff_of(self.tools.before, self.current_text, budget)
846
+ if changed < review.MIN_CHANGED_LINES:
847
+ return []
848
+ request = next(
849
+ (
850
+ context_tools.request_text(str(m.get("content", "")))
851
+ for m in reversed(self.messages)
852
+ if m.get("role") == "user"
853
+ and not str(m.get("content", "")).startswith(("[lcode]", sessions.SUMMARY_PREFIX))
854
+ ),
855
+ "",
856
+ )
857
+ try:
858
+ with self.console.status("Reviewing the change against your request…", spinner="dots"):
859
+ problems = review.run(self, request, answer, diff)
860
+ except OllamaError as e:
861
+ self.say(f"The review didn't run: {e}", warning=True)
862
+ return []
863
+ self.review_results = problems
864
+ if problems:
865
+ self.say(
866
+ f"Review: {len(problems)} point(s) sent back to the model. First: {problems[0][:160]}", warning=True
867
+ )
868
+ else:
869
+ self.say("Review: the change does what was asked.")
870
+ return problems
871
+
872
+ def current_text(self, path: str) -> str | None:
873
+ p = self.tools.resolve(path)
874
+ try:
875
+ return self.tools.read_text(p) if p.is_file() else None
876
+ except OSError:
877
+ return None
878
+
826
879
  def checks_prompt(self) -> str:
827
880
  check = self.project_check()
828
881
  if check is None:
@@ -1018,6 +1071,9 @@ class Agent:
1018
1071
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
1019
1072
  self.repeats.reset()
1020
1073
  self.check_results = []
1074
+ self.review_results = None
1075
+ self.tools.before = {}
1076
+ reviewed = False
1021
1077
  changed = verified = False # files changed in this request; the checks passed since
1022
1078
  check_rounds = 0
1023
1079
  malformed = 0
@@ -1079,6 +1135,12 @@ class Agent:
1079
1135
  changed = False # run them again only if the model changes something more
1080
1136
  continue
1081
1137
  verified = True
1138
+ if not reviewed and self.tools.before and self.reviewing():
1139
+ reviewed = True
1140
+ problems = self.self_review(reply.get("content") or "")
1141
+ if problems:
1142
+ self.messages.append({"role": "user", "content": review.feedback(problems)})
1143
+ continue
1082
1144
  break
1083
1145
  done: dict[int, str] = {} # results of subagents that ran together
1084
1146
  together = [
@@ -1289,6 +1351,8 @@ class Agent:
1289
1351
  self.messages, keep=1, removed=removed
1290
1352
  ) # not enough: before the last request
1291
1353
  context_tools.note_removed(self.messages, removed)
1354
+ if saved:
1355
+ context_tools.pin(self.messages, context_tools.task_sheet(self))
1292
1356
  if saved:
1293
1357
  self.sent_chars = 0 # the conversation changed: the model reads it again
1294
1358
  self.pruned += 1
@@ -1333,7 +1397,9 @@ class Agent:
1333
1397
  {"role": "system", "content": self.system_prompt()},
1334
1398
  {
1335
1399
  "role": "user",
1336
- "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1400
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}"
1401
+ + context_tools.requests_block(requests)
1402
+ + (f"\n\n{sheet}" if (sheet := context_tools.task_sheet(self)) else ""),
1337
1403
  },
1338
1404
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1339
1405
  ]
lcode/api.py CHANGED
@@ -77,6 +77,7 @@ class Result:
77
77
  files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
78
78
  usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
79
79
  checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
80
+ review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
80
81
  seconds: float = 0.0
81
82
  error: str = ""
82
83
 
@@ -198,6 +199,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
198
199
  secret_check=cfg["secret_check"],
199
200
  repeat_limit=cfg["repeat_limit"],
200
201
  checks=cfg["checks"],
202
+ review=cfg["review"],
201
203
  compact_at=cfg["compact_at"],
202
204
  )
203
205
  agent = Agent(ollama, settings, cwd, console=console)
@@ -306,6 +308,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
306
308
  files_changed=files,
307
309
  usage=usage,
308
310
  checks=list(agent.check_results),
311
+ review=agent.review_results,
309
312
  seconds=round(time.monotonic() - started, 2),
310
313
  error=error,
311
314
  )
lcode/cli.py CHANGED
@@ -581,6 +581,7 @@ def cmd_bench(args) -> None:
581
581
  prune=not args.no_prune,
582
582
  compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
583
583
  checks=cfg["checks"] if args.checks is None else args.checks,
584
+ review=cfg["review"] if args.review is None else args.review,
584
585
  repo_map=args.repo_map,
585
586
  embed_model=cfg["embed_model"] if args.code_search else "off",
586
587
  )
@@ -775,6 +776,9 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
775
776
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
776
777
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
777
778
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
779
+ p.add_argument(
780
+ "--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
781
+ )
778
782
  p.add_argument(
779
783
  "--checks",
780
784
  metavar="auto|off|COMMAND",
lcode/config.py CHANGED
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
+ "review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
62
63
  "checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
63
64
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
64
65
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
lcode/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/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/review.py ADDED
@@ -0,0 +1,103 @@
1
+ """A second look at a change before the answer goes to the user.
2
+
3
+ Models claim more than they did: "users can now shrink the window" without having checked, half of a
4
+ two-part request done, a debug print left behind. So when a request changed files, lcode asks the model to
5
+ step back and check its change against the request (what was asked, what the diff shows, what the answer
6
+ claims) before the answer counts. Real problems go back to the model once; the user sees a line about it.
7
+
8
+ The review is asked on top of the conversation, not in a fresh one: the model server keeps the processed
9
+ conversation cached, and a separate review prompt would push it out, so the next step would have to read the
10
+ whole conversation again, tens of seconds on a small GPU. The question tells the model to judge only from the
11
+ request and the diff.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ from typing import TYPE_CHECKING
18
+
19
+ from lcode.gitflow import parse_json
20
+
21
+ if TYPE_CHECKING:
22
+ from lcode.agent import Agent
23
+
24
+ MIN_CHANGED_LINES = 1 # even one line: a one-line bug fix is where a wrong fix hides
25
+ MAX_PROBLEMS = 5
26
+ SCHEMA = {
27
+ "type": "object",
28
+ "properties": {"problems": {"type": "array", "items": {"type": "string"}}},
29
+ "required": ["problems"],
30
+ }
31
+ PROMPT = """[lcode] Before your answer goes to the user, review your change as a strict reviewer would: judge it only by what the user asked and what the diff shows, not by what you meant to do.
32
+
33
+ The user's request:
34
+ <request>
35
+ {request}
36
+ </request>
37
+
38
+ Your answer to them:
39
+ <answer>
40
+ {answer}
41
+ </answer>
42
+
43
+ {checks}The change (unified diff of the files changed in this request):
44
+ <diff>
45
+ {diff}
46
+ </diff>
47
+
48
+ Find real problems only, each one you can point to in the request or the diff:
49
+ 1. Something the user asked for that the change doesn't do, or only partly does.
50
+ 2. A claim in your answer that the diff doesn't support, or something called tested or working that nothing shows was run.
51
+ 3. A mistake in the diff: a bug, a broken edge case the request mentions, leftover debugging (prints, commented-out code), an unfinished TODO.
52
+ No style preferences, no suggestions beyond the request. Answer in JSON: {{"problems": ["…"]}}, with an empty list when the change does what was asked."""
53
+
54
+ FEEDBACK = """[lcode] A review of your change against the user's request found:
55
+ {problems}
56
+
57
+ Fix what's real; if a point is wrong, say why in your answer. Then finish."""
58
+
59
+
60
+ def diff_of(before: dict[str, str | None], read, budget: int) -> tuple[str, int]:
61
+ """The unified diff of the files changed in the request, and how many lines it changes."""
62
+ parts, changed = [], 0
63
+ for path, old in before.items():
64
+ new = read(path)
65
+ if new is None or new == old:
66
+ continue
67
+ lines = list(
68
+ difflib.unified_diff((old or "").splitlines(True), new.splitlines(True), f"a/{path}", f"b/{path}", n=2)
69
+ )
70
+ changed += sum(1 for line in lines if line[:1] in "+-" and not line.startswith(("+++", "---")))
71
+ parts.append("".join(lines))
72
+ text = "".join(parts)
73
+ if len(text) > budget:
74
+ text = text[:budget] + f"\n[… the rest of the diff ({len(text) - budget} characters) is cut]"
75
+ return text, changed
76
+
77
+
78
+ def run(agent: Agent, request: str, answer: str, diff: str) -> list[str]:
79
+ """Ask for the review; the problems it found (empty when none, or when the answer can't be read)."""
80
+ checks = ""
81
+ if agent.check_results:
82
+ last = agent.check_results[-1]
83
+ code = last.get("exit_code")
84
+ state = "passed" if code == 0 else "didn't run" if code is None else f"failed (exit code {code})"
85
+ checks = f"The project's checks (`{last.get('command')}`) {state} after the change.\n\n"
86
+ prompt = PROMPT.format(request=request[:4000], answer=answer[:3000] or "(none)", checks=checks, diff=diff)
87
+ payload = {
88
+ "model": agent.settings.model,
89
+ "messages": [*agent.messages, {"role": "user", "content": prompt}],
90
+ "tools": agent.tool_schemas(), # as in the session, so the cached conversation is reused
91
+ "think": agent.settings.think, # without reasoning it missed every gap it caught with it, and wasn't faster
92
+ "format": SCHEMA,
93
+ "keep_alive": agent.settings.keep_alive,
94
+ "options": agent.options(),
95
+ }
96
+ reply = agent.ollama.chat(payload)
97
+ data = parse_json((reply.get("message") or {}).get("content") or "")
98
+ problems = data.get("problems") if isinstance(data.get("problems"), list) else []
99
+ return [str(p).strip()[:500] for p in problems if str(p).strip()][:MAX_PROBLEMS]
100
+
101
+
102
+ def feedback(problems: list[str]) -> str:
103
+ return FEEDBACK.format(problems="\n".join(f"- {p}" for p in problems))
lcode/tools.py CHANGED
@@ -24,6 +24,7 @@ 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
@@ -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",
@@ -161,7 +163,8 @@ SCHEMAS = [
161
163
  _fn(
162
164
  "todo_write",
163
165
  "Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
164
- "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.",
165
168
  {
166
169
  "todos": {
167
170
  "type": "array",
@@ -173,7 +176,12 @@ SCHEMAS = [
173
176
  },
174
177
  "required": ["content", "status"],
175
178
  },
176
- }
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
+ },
177
185
  },
178
186
  ["todos"],
179
187
  ),
@@ -438,6 +446,9 @@ class Toolbox:
438
446
  self.agent = agent
439
447
  self.read_mtimes: dict[str, float] = {}
440
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
451
+ self.before: dict[str, str | None] = {} # files changed in this request: their text before (None: new)
441
452
  self.todos: list[dict] = []
442
453
  # An editor's buffers (lcode acp): read unsaved changes and write through the editor.
443
454
  self.reader: Callable[[Path], str | None] | None = None
@@ -860,11 +871,14 @@ class Toolbox:
860
871
  p.parent.mkdir(parents=True, exist_ok=True)
861
872
  fmt = self.formats.get(str(p)) or (decode(p.read_bytes())[1] if p.exists() else TextFormat())
862
873
  data = encode(content, fmt) # before anything is written: it may not be possible
874
+ if self.rel(p) not in self.before:
875
+ self.before[self.rel(p)] = decode(p.read_bytes())[0] if p.exists() else None
863
876
  if self.writer is not None:
864
877
  self.writer(p, content) # the editor updates its buffer (and the file)
865
878
  if not p.exists() or p.read_bytes() != data:
866
879
  p.write_bytes(data) # the file on disk must match, for checkpoints and the tools
867
880
  self.read_mtimes[str(p)] = p.stat().st_mtime
881
+ self.changed[self.rel(p)] = None
868
882
 
869
883
  def t_write_file(self, path: str, content: str) -> str:
870
884
  p = self.path(path)
@@ -1248,12 +1262,29 @@ class Toolbox:
1248
1262
  return f"[{describe(job)}]" + (f"\nIts last output:\n{last}" if last else "")
1249
1263
 
1250
1264
  # -- planning
1251
- def t_todo_write(self, todos: list | str) -> str:
1265
+ def t_todo_write(self, todos: list | str, notes: list | str | None = None) -> str:
1252
1266
  if isinstance(todos, str):
1253
1267
  todos = json.loads(todos)
1254
1268
  self.todos = list(todos)
1269
+ # Small models often put a note inside an item instead of in `notes`: keep it either way.
1270
+ attached = [
1271
+ str(todo.get(key)).strip()
1272
+ for todo in self.todos
1273
+ if isinstance(todo, dict)
1274
+ for key in ("notes", "note", "details")
1275
+ if str(todo.get(key) or "").strip()
1276
+ ]
1277
+ if attached:
1278
+ notes = [*([] if notes is None else ([notes] if isinstance(notes, str) else list(notes))), *attached]
1279
+ if notes is not None:
1280
+ if isinstance(notes, str):
1281
+ try:
1282
+ notes = json.loads(notes)
1283
+ except ValueError:
1284
+ notes = [notes]
1285
+ self.notes = [str(n).strip()[:MAX_NOTE] for n in notes if str(n).strip()][:MAX_NOTES]
1255
1286
  self.show_todos()
1256
- return "Todo list updated."
1287
+ return "Todo list updated." + (f" {len(self.notes)} note(s) kept." if notes is not None else "")
1257
1288
 
1258
1289
  def show_todos(self) -> None:
1259
1290
  icons = {"completed": "[green]✔[/]", "in_progress": "[yellow]◐[/]", "pending": "[dim]○[/]"}
@@ -1265,4 +1296,6 @@ class Toolbox:
1265
1296
  elif status == "in_progress":
1266
1297
  text = f"[bold]{text}[/]"
1267
1298
  rows.append(f"{icons.get(status, '○')} {text}")
1299
+ if self.notes:
1300
+ rows += ["", *(f"[dim]•[/] {escape(note)}" for note in self.notes)]
1268
1301
  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.30.0
3
+ Version: 0.32.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -1,9 +1,9 @@
1
- lcode/__init__.py,sha256=_ncPdkojpmAnL6Af19KuRAd4o_TixpnzWrgz3usKy0A,473
1
+ lcode/__init__.py,sha256=WYtKNLgt5PjAq5IrasutTzXxW9RCwY4T0ZQs0OxnnLs,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=CVOyaV_KJQor1zlW2zou3iQXzf9UPV-3emTmuqJ5JVE,69562
6
- lcode/api.py,sha256=PhG8PZReM7DXSxG6wf7mEjAKx_TAsgj3xHApacrXGCM,15684
5
+ lcode/agent.py,sha256=ztjxSzYkvUvwuHVEI9piFnuOV8glHBe9ULNIk7x2HKQ,72682
6
+ lcode/api.py,sha256=YTfXVMSYSp1EC3tNI_fW-JcNBgrdFTN7V57HNIzB7Kc,15861
7
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
8
  lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
@@ -11,11 +11,11 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
13
  lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
14
- lcode/cli.py,sha256=svnW6pfLtqiMXbA3wMlYESKGgGe6Xb9wlWkqg6Cz0O0,41081
14
+ lcode/cli.py,sha256=pB8gnPuVkqanm9amuLKWanH3_0onpn8oS8botxl-DSw,41292
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
- lcode/config.py,sha256=KSjSBvxA9nmd4h4QYk3xp8BQ7C04pjTULsSn0jcQwBc,11301
18
- lcode/context.py,sha256=WSMiitscDh2Juy4Hx0x9EPNWrn804IbOWRZly1-DU38,17022
17
+ lcode/config.py,sha256=CujFZiUPsbZERjPw2j_uTBnZqUfCgYz1hvcvQSSMCu0,11421
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
@@ -33,14 +33,15 @@ lcode/permissions.py,sha256=3OPVVth6uTE5kcAWMZ8X_vUnFRbf6STEHAJimZX0Yu4,12118
33
33
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
34
34
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
35
35
  lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
36
- lcode/repl.py,sha256=ayrKySMbbRbax_WMjqYBN4fkcDpzuHniefJC3DZ4-2Y,50687
36
+ lcode/repl.py,sha256=0f3nojURlQE8Pm_JkXvh9y7nEg1CHG3xanZIs0osPxw,52107
37
37
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
38
38
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
39
+ lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
39
40
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
40
41
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
41
42
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
42
43
  lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
43
- lcode/tools.py,sha256=1zH4hkEyHQz4byV348J4zfe0VzwvjmlH1MOqiobznbA,59446
44
+ lcode/tools.py,sha256=5qaQISmPqkQiM7i1b1_3lDxbokugGRyGNOmSHOBAFDQ,61426
44
45
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
45
46
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
46
47
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -60,8 +61,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
60
61
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
61
62
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
62
63
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
63
- lcode_cli-0.30.0.dist-info/METADATA,sha256=s6dAfe3HCeh9LIPNLLoYY3VGXfGT6b8ZW0pZbERa6X0,12620
64
- lcode_cli-0.30.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
65
- lcode_cli-0.30.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
66
- lcode_cli-0.30.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
67
- lcode_cli-0.30.0.dist-info/RECORD,,
64
+ lcode_cli-0.32.0.dist-info/METADATA,sha256=QWfst181FXU5mfBDoHdf_9FpIj_SJvVAkbSdvNNv5lo,12620
65
+ lcode_cli-0.32.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
66
+ lcode_cli-0.32.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
67
+ lcode_cli-0.32.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
68
+ lcode_cli-0.32.0.dist-info/RECORD,,