lcode-cli 0.11.1__py3-none-any.whl → 0.12.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
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.11.1"
3
+ __version__ = "0.12.0"
lcode/agent.py CHANGED
@@ -22,6 +22,7 @@ from rich.panel import Panel
22
22
  from rich.text import Text
23
23
 
24
24
  from lcode import catalog, extensions, limits, planning, sessions, subagents, vision, web
25
+ from lcode import context as context_tools
25
26
  from lcode import memory as memory_notes
26
27
  from lcode.checkpoints import Checkpoints
27
28
  from lcode.config import format_tokens
@@ -50,6 +51,7 @@ SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
50
51
  MAX_STEPS_PER_TURN = 150
51
52
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON # safety cap on tool-call iterations for one request
52
53
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
54
+ PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
53
55
  PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
54
56
 
55
57
  SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
@@ -173,6 +175,7 @@ class Settings:
173
175
  subagents: bool = False # the agent tool (lcode.subagents)
174
176
  trust_project: bool = False # use the repository's own commands, skills and agents (lcode.extensions)
175
177
  skills: str = "off" # all, lcode (only lcode's own skill folders) or off
178
+ prune: bool = True # remove old tool output before summarizing (lcode.context)
176
179
  max_parallel_agents: int = 1
177
180
 
178
181
 
@@ -202,6 +205,7 @@ class Agent:
202
205
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
203
206
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
204
207
  self.plan = "" # the plan the user approved; kept through compaction
208
+ self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
205
209
  self._extensions: tuple[Path, extensions.Extensions] | None = None
206
210
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
207
211
  # Set on subagents (see lcode.subagents):
@@ -775,9 +779,24 @@ class Agent:
775
779
 
776
780
  # -- context management
777
781
  def maybe_compact(self) -> None:
778
- if self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context:
779
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
780
- self.compact()
782
+ if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
783
+ return
784
+ if self.settings.prune:
785
+ saved = context_tools.prune(self.messages) # tool output from before the last two requests
786
+ if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context:
787
+ saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request
788
+ if saved:
789
+ self.pruned += 1
790
+ self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
791
+ self.console.print(
792
+ f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
793
+ "make room.[/]"
794
+ )
795
+ if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
796
+ return
797
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
798
+ self.compacted += 1
799
+ self.compact()
781
800
 
782
801
  def compact(self, focus: str = "") -> None:
783
802
  if len(self.messages) <= 2:
lcode/bench.py CHANGED
@@ -784,6 +784,9 @@ class ModelRun:
784
784
  tasks: list[TaskResult] = field(default_factory=list)
785
785
  error: str | None = None # the model couldn't run at all
786
786
  interrupted: bool = False
787
+ session: bool = False # all tasks in one conversation (--session)
788
+ pruned: int = 0 # times old tool output was removed to make room
789
+ compacted: int = 0 # times the conversation was summarized
787
790
 
788
791
  @property
789
792
  def passed(self) -> int:
@@ -847,14 +850,28 @@ def count_tool_errors(messages: list[dict]) -> int:
847
850
 
848
851
 
849
852
  def run_task(
850
- ollama: Ollama, settings: Settings, task: Task, timeout: float, log: Console, keep: bool, usage: dict
853
+ ollama: Ollama,
854
+ settings: Settings,
855
+ task: Task,
856
+ timeout: float,
857
+ log: Console,
858
+ keep: bool,
859
+ usage: dict,
860
+ agent: Agent | None = None,
851
861
  ) -> TaskResult:
862
+ """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session)."""
852
863
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
853
864
  try:
854
865
  for rel, content in task.files.items():
855
866
  (folder / rel).parent.mkdir(parents=True, exist_ok=True)
856
867
  (folder / rel).write_text(content)
857
- agent = Agent(ollama, settings, folder, console=log)
868
+ if agent is None:
869
+ agent = Agent(ollama, settings, folder, console=log)
870
+ else: # the same conversation moves on to this task's folder, as with /cd
871
+ agent.cwd = folder.resolve()
872
+ agent.messages[0]["content"] = agent.system_prompt()
873
+ before = dict(agent.usage)
874
+ first = len(agent.messages)
858
875
  error = None
859
876
  started = time.monotonic()
860
877
  try:
@@ -866,7 +883,7 @@ def run_task(
866
883
  error = str(e).splitlines()[0][:200]
867
884
  seconds = time.monotonic() - started
868
885
  for key, value in agent.usage.items():
869
- usage[key] = usage.get(key, 0) + value
886
+ usage[key] = usage.get(key, 0) + value - before.get(key, 0)
870
887
  answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
871
888
  try:
872
889
  check = task.check(folder, answer)
@@ -882,8 +899,8 @@ def run_task(
882
899
  passed=check.passed and error is None,
883
900
  seconds=round(seconds, 1),
884
901
  detail=error or check.detail,
885
- steps=sum(1 for m in agent.messages if m.get("role") == "assistant"),
886
- tool_errors=count_tool_errors(agent.messages),
902
+ steps=agent.usage["requests"] - before.get("requests", 0),
903
+ tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
887
904
  error=error,
888
905
  )
889
906
 
@@ -940,8 +957,10 @@ def run_model(
940
957
  timeout: float = DEFAULT_TIMEOUT,
941
958
  keep: bool = False,
942
959
  verbose: bool = False,
960
+ session: bool = False,
943
961
  ) -> ModelRun:
944
- run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think)
962
+ run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
963
+ shared: Agent | None = None # with --session, one conversation for all tasks
945
964
  console.print(
946
965
  f"\n[bold]{escape(settings.model)}[/] · {format_tokens(settings.context)} context · "
947
966
  f"reasoning {'on' if settings.think else 'off'} · {len(tasks)} task{'' if len(tasks) == 1 else 's'}"
@@ -969,13 +988,17 @@ def run_model(
969
988
  console.print(f" [dim]Loaded in {run.load_seconds:.0f}s{where}{reads}[/]")
970
989
  for task in tasks:
971
990
  log = console if verbose else Console(file=io.StringIO(), width=120, force_terminal=False)
991
+ if session and shared is None:
992
+ shared = Agent(ollama, settings, Path(tempfile.gettempdir()), console=log)
993
+ if shared is not None:
994
+ shared.console = log
972
995
  try:
973
996
  if verbose:
974
997
  console.rule(f"{task.id}: {task.title}")
975
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
998
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
976
999
  else:
977
1000
  with console.status(Elapsed(f" {task.id:<13} {task.title}")):
978
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
1001
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
979
1002
  except KeyboardInterrupt:
980
1003
  console.print(" [yellow]Stopped.[/]")
981
1004
  run.interrupted = True
@@ -985,6 +1008,8 @@ def run_model(
985
1008
  console.print(
986
1009
  f" {mark} {task.id:<13} {task.title:<32} {result.seconds:>5.0f}s [dim]{escape(result.detail)}[/]"
987
1010
  )
1011
+ if shared is not None:
1012
+ run.pruned, run.compacted = shared.pruned, shared.compacted
988
1013
  if run.memory_gb is None:
989
1014
  run.memory_gb, run.gpu_percent = memory_use(ollama, settings.model)
990
1015
  # lcode lowers the batch size or context if the GPU runs out of memory; report what was used.
@@ -1001,11 +1026,12 @@ def duration(seconds: float) -> str:
1001
1026
 
1002
1027
  def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, list[str]]]:
1003
1028
  rows = []
1004
- for task in tasks:
1029
+ for task in {t.id: t for t in tasks}.values(): # with --rounds a task runs several times: one row, a mark each
1005
1030
  cells = []
1006
1031
  for run in runs:
1007
- result = next((t for t in run.tasks if t.id == task.id), None)
1008
- cells.append("" if result is None else f"{'✓' if result.passed else '✗'} {duration(result.seconds)}")
1032
+ results = [t for t in run.tasks if t.id == task.id]
1033
+ marks = "".join("✓" if r.passed else "✗" for r in results)
1034
+ cells.append(f"{marks} {duration(sum(r.seconds for r in results))}" if results else "")
1009
1035
  rows.append((f"{task.id}: {task.title}", cells))
1010
1036
 
1011
1037
  def cell(run: ModelRun, value: str) -> str:
@@ -1023,6 +1049,13 @@ def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, lis
1023
1049
  ),
1024
1050
  ("Context", [cell(r, format_tokens(r.context)) for r in runs]),
1025
1051
  ]
1052
+ if any(r.session for r in runs):
1053
+ rows.append(
1054
+ (
1055
+ "One conversation",
1056
+ [cell(r, f"pruned {r.pruned}×, summarized {r.compacted}×" if r.session else "no") for r in runs],
1057
+ )
1058
+ )
1026
1059
  return rows
1027
1060
 
1028
1061
 
lcode/cli.py CHANGED
@@ -436,7 +436,7 @@ def cmd_bench(args) -> None:
436
436
  try:
437
437
  cfg = config.load()
438
438
  context = parse_context(args.context) if args.context else bench.DEFAULT_CONTEXT
439
- tasks = bench.select_tasks(args.tasks)
439
+ tasks = bench.select_tasks(args.tasks) * max(1, args.rounds)
440
440
  except (ConfigError, ValueError) as e:
441
441
  fail(str(e))
442
442
  if args.timeout <= 0:
@@ -483,8 +483,11 @@ def cmd_bench(args) -> None:
483
483
  permission_mode="yolo",
484
484
  web="off",
485
485
  checkpoints=False,
486
+ prune=not args.no_prune,
487
+ )
488
+ run = bench.run_model(
489
+ ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session
486
490
  )
487
- run = bench.run_model(ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose)
488
491
  runs.append(run)
489
492
  if run.interrupted:
490
493
  break
@@ -566,6 +569,7 @@ def cmd_chat(args) -> None:
566
569
  max_parallel_agents=cfg["max_parallel_agents"],
567
570
  trust_project=trust_project,
568
571
  skills=cfg["skills"],
572
+ prune=cfg["prune"],
569
573
  )
570
574
  agent = Agent(ollama, settings, cwd, console=console)
571
575
  agent.interactive = not args.prompt
@@ -651,6 +655,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
651
655
  p.add_argument("--keep", action="store_true", help="keep each task's folder and transcript for inspection")
652
656
  p.add_argument("-v", "--verbose", action="store_true", help="show the model working, like a normal session")
653
657
  p.add_argument("--list", action="store_true", help="list the tasks and exit")
658
+ p.add_argument(
659
+ "--session",
660
+ action="store_true",
661
+ help="run all tasks in one conversation, to test a long session and its context management",
662
+ )
663
+ p.add_argument(
664
+ "--rounds", type=int, default=1, help="run the tasks this many times (with --session: a longer session)"
665
+ )
666
+ p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
654
667
  subs["bench"] = p
655
668
  from lcode.mcp.commands import build_parser as mcp_parser
656
669
 
lcode/config.py CHANGED
@@ -49,6 +49,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
49
49
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
50
50
  "memory": ("ask", str, "notes that carry over to later sessions: off | ask (confirm each) | auto"),
51
51
  "skills": ("all", str, "skills to offer the model: all (also other agents' folders) | lcode | off"),
52
+ "prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"),
52
53
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
53
54
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
54
55
  }
lcode/context.py ADDED
@@ -0,0 +1,243 @@
1
+ """Keeping the context window for what matters, which counts most at 32K.
2
+
3
+ Most of a long session's context is old tool output: files read many turns ago, command output that
4
+ was already acted on. Before lcode summarizes a conversation (which loses detail), it prunes that:
5
+ old tool results become one-line stubs that say what was there and how to get it back, and earlier
6
+ reads of a file that was read again later go too. Recent requests are left alone.
7
+
8
+ Long tool output is cut smarter: the head, the tail and the lines that look like errors stay, and the
9
+ full output is saved to a file the model can read if it needs more.
10
+
11
+ `breakdown` shows what uses the context, by category, for /context.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ from lcode import config
24
+ from lcode.sessions import SUMMARY_PREFIX
25
+
26
+ if TYPE_CHECKING:
27
+ from lcode.agent import Agent
28
+
29
+ KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
30
+ MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
31
+ PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
32
+ KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
33
+ ERROR_LINE = re.compile(
34
+ r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
35
+ )
36
+ MAX_ERROR_LINES = 40
37
+ OUTPUT_DAYS = 7 # saved full outputs are deleted after this
38
+
39
+
40
+ # ----------------------------------------------------------------------------- pruning
41
+
42
+
43
+ def calls_for_results(messages: list[dict]) -> dict[int, tuple[str, dict]]:
44
+ """For each tool message (by index), the name and arguments of the call it answers."""
45
+ from lcode.agent import call_arguments
46
+
47
+ found: dict[int, tuple[str, dict]] = {}
48
+ pending: list[tuple[str, dict]] = []
49
+ for i, message in enumerate(messages):
50
+ if message.get("role") == "assistant":
51
+ pending = [
52
+ (c.get("function", {}).get("name", ""), call_arguments(c)) for c in message.get("tool_calls") or []
53
+ ]
54
+ elif message.get("role") == "tool":
55
+ found[i] = pending.pop(0) if pending else (message.get("tool_name", ""), {})
56
+ return found
57
+
58
+
59
+ def stub(name: str, args: dict, content: str) -> str:
60
+ """A one-line stand-in for an old tool result."""
61
+ if name == "read_file":
62
+ span = ""
63
+ if args.get("offset") or args.get("limit"):
64
+ start = int(args.get("offset") or 1)
65
+ span = f" from line {start}"
66
+ return f"{PRUNED}read {args.get('path', 'a file')}{span} earlier; removed to save context. Read it again if you need it.]"
67
+ if name == "bash":
68
+ command = " ".join(str(args.get("command", "")).split())
69
+ command = command if len(command) <= 80 else command[:79] + "…"
70
+ code = re.search(r"\[exit code: (-?\d+)\]\s*$", content)
71
+ status = f", exit code {code.group(1)}" if code else ""
72
+ return f"{PRUNED}ran `{command}` earlier{status}; its output was removed to save context.]"
73
+ if name in ("grep", "glob", "list_dir"):
74
+ what = args.get("pattern") or args.get("path") or ""
75
+ return f"{PRUNED}{name}({what}) results removed to save context; run it again if needed.]"
76
+ if name == "web_fetch":
77
+ return f"{PRUNED}fetched {args.get('url', 'a page')} earlier; its text was removed to save context.]"
78
+ if name == "web_search":
79
+ return f"{PRUNED}search results for {args.get('query', '')!r} removed to save context.]"
80
+ if name == "view_image":
81
+ return f"{PRUNED}the description of {args.get('path', 'an image')} was removed to save context.]"
82
+ return f"{PRUNED}{name} result removed to save context.]"
83
+
84
+
85
+ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
86
+ """The index of the oldest of the last `keep` requests from the user (results after it stay)."""
87
+ requests = [
88
+ i
89
+ for i, m in enumerate(messages)
90
+ if m.get("role") == "user" and not str(m.get("content", "")).startswith(("[lcode]", SUMMARY_PREFIX))
91
+ ]
92
+ return requests[-keep] if len(requests) >= keep else 0
93
+
94
+
95
+ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
96
+ """Replace stale tool results with stubs, in place. Returns the number of characters saved."""
97
+ calls = calls_for_results(messages)
98
+ edge = boundary(messages, keep)
99
+ # A read is superseded by a later read of the whole file, or of the same lines: not by a later
100
+ # read of other lines, which would leave the model without the part it read first.
101
+ last_full: dict[str, int] = {}
102
+ last_same: dict[tuple, int] = {}
103
+ for i, (name, args) in calls.items():
104
+ if name == "read_file" and not str(messages[i].get("content", "")).startswith("Error"):
105
+ path = str(args.get("path", ""))
106
+ span = (path, int(args.get("offset") or 1), int(args.get("limit") or 0))
107
+ last_same[span] = i
108
+ if span[1:] == (1, 0):
109
+ last_full[path] = i
110
+ saved = 0
111
+ for i, (name, args) in calls.items():
112
+ content = str(messages[i].get("content", ""))
113
+ if name in KEEP_TOOLS or content.startswith(PRUNED) or len(content) < MIN_PRUNE_CHARS:
114
+ continue
115
+ span = (str(args.get("path", "")), int(args.get("offset") or 1), int(args.get("limit") or 0))
116
+ reread = name == "read_file" and (last_full.get(span[0], i) > i or last_same.get(span, i) > i)
117
+ if i >= edge and not reread:
118
+ continue
119
+ new = stub(name, args, content)
120
+ if reread:
121
+ new = new.replace("earlier; removed", "earlier (read again later); removed")
122
+ messages[i]["content"] = new
123
+ saved += len(content) - len(new)
124
+ return saved
125
+
126
+
127
+ # ----------------------------------------------------------------------------- long output
128
+
129
+
130
+ def outputs_dir() -> Path:
131
+ return config.STATE_DIR / "outputs"
132
+
133
+
134
+ def save_output(text: str, label: str) -> Path | None:
135
+ """Keep the full text of a long result where the model can read it; old ones are cleaned up."""
136
+ folder = outputs_dir()
137
+ try:
138
+ folder.mkdir(parents=True, exist_ok=True)
139
+ cutoff = time.time() - OUTPUT_DAYS * 86400
140
+ for old in folder.glob("*.txt"):
141
+ if old.stat().st_mtime < cutoff:
142
+ old.unlink(missing_ok=True)
143
+ path = folder / f"{time.strftime('%Y%m%d-%H%M%S')}-{re.sub(r'[^a-z0-9]+', '-', label.lower())[:30]}.txt"
144
+ path.write_text(text)
145
+ return path
146
+ except OSError:
147
+ return None
148
+
149
+
150
+ def shorten(text: str, limit: int, label: str = "output") -> str:
151
+ """Head, tail and the error-looking lines in between; the full text goes to a file."""
152
+ if len(text) <= limit:
153
+ return text
154
+ head, tail = text[: limit * 3 // 5], text[-limit // 4 :]
155
+ middle = text[len(head) : len(text) - len(tail)]
156
+ errors, budget = [], limit // 6
157
+ for line in middle.splitlines():
158
+ if ERROR_LINE.search(line) and len(errors) < MAX_ERROR_LINES:
159
+ line = line.strip()[:300]
160
+ if budget - len(line) < 0:
161
+ break
162
+ errors.append(line)
163
+ budget -= len(line) + 1
164
+ cut = len(middle) - sum(len(e) + 1 for e in errors)
165
+ note = f"\n\n... [{cut} characters cut"
166
+ if errors:
167
+ note += f"; {len(errors)} line(s) from them that look like errors:]\n" + "\n".join(errors) + "\n[...]\n\n"
168
+ else:
169
+ note += "] ...\n\n"
170
+ saved = save_output(text, label)
171
+ where = (
172
+ f"\n[The full {label} ({len(text)} characters) is in {saved}: read parts of it with read_file if you need them.]"
173
+ if saved
174
+ else ""
175
+ )
176
+ return f"{head}{note}{tail}{where}"
177
+
178
+
179
+ # ----------------------------------------------------------------------------- what uses the context
180
+
181
+
182
+ @dataclass
183
+ class Part:
184
+ name: str
185
+ chars: int
186
+ detail: str = ""
187
+
188
+ @property
189
+ def tokens(self) -> int:
190
+ return self.chars // 3
191
+
192
+
193
+ def breakdown(agent: Agent) -> list[Part]:
194
+ """What the next request sends, by category (sizes are estimates: ~3 characters per token)."""
195
+ from lcode import extensions
196
+
197
+ system = agent.messages[0]["content"] if agent.messages else ""
198
+ pieces = {
199
+ "AGENTS.md": len(next((system[system.find(h) :] for h in ["\n# Project instructions"] if h in system), "")),
200
+ "memory notes": len(agent.memory().prompt(agent.settings.context)) if agent.settings.memory != "off" else 0,
201
+ "skills": len(extensions.skills_prompt(agent.extensions().skills, agent.settings.context)),
202
+ "MCP servers": len(agent._mcp_prompt),
203
+ }
204
+ if pieces["AGENTS.md"]: # it runs to the end of the prompt, which may include memory and MCP after it
205
+ pieces["AGENTS.md"] = max(0, pieces["AGENTS.md"] - pieces["memory notes"] - pieces["MCP servers"])
206
+ parts = [Part("system prompt", len(system) - sum(pieces.values()), "instructions, environment, repository layout")]
207
+ parts += [Part(name, size) for name, size in pieces.items() if size]
208
+ schemas = agent.tool_schemas()
209
+ mcp = [s for s in schemas if agent.mcp and agent.mcp.owns(s["function"]["name"])]
210
+ parts.append(
211
+ Part(
212
+ "tool definitions",
213
+ len(json.dumps([s for s in schemas if s not in mcp])),
214
+ f"{len(schemas) - len(mcp)} tools",
215
+ )
216
+ )
217
+ if mcp:
218
+ parts.append(Part("MCP tool definitions", len(json.dumps(mcp)), f"{len(mcp)} tools"))
219
+ yours = files = replies = 0
220
+ results: dict[str, int] = {}
221
+ calls = calls_for_results(agent.messages)
222
+ for i, m in enumerate(agent.messages[1:], 1):
223
+ content = str(m.get("content") or "")
224
+ if m.get("role") == "user":
225
+ attached = sum(
226
+ len(block)
227
+ for block in re.findall(r"<(?:file|directory|image) path=.*?</(?:file|directory|image)>", content, re.S)
228
+ )
229
+ files += attached
230
+ yours += len(content) - attached
231
+ elif m.get("role") == "assistant":
232
+ replies += len(content) + len(json.dumps(m.get("tool_calls") or []))
233
+ elif m.get("role") == "tool":
234
+ name = calls.get(i, (m.get("tool_name", "?"), {}))[0]
235
+ name = "MCP tools" if name.startswith("mcp") else name
236
+ results[name] = results.get(name, 0) + len(content)
237
+ parts.append(Part("your messages", yours))
238
+ if files:
239
+ parts.append(Part("attached files", files))
240
+ parts.append(Part("model's replies", replies, "answers and tool calls"))
241
+ for name, size in sorted(results.items(), key=lambda kv: -kv[1]):
242
+ parts.append(Part(f"tool results: {name}", size))
243
+ return parts
lcode/repl.py CHANGED
@@ -26,6 +26,7 @@ from rich.table import Table
26
26
  from rich.text import Text
27
27
 
28
28
  from lcode import __version__, catalog, extensions, limits, sessions, web
29
+ from lcode import context as context_tools
29
30
  from lcode import memory as memory_notes
30
31
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
31
32
  from lcode.catalog import MIN_USEFUL_CONTEXT
@@ -217,6 +218,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
217
218
  if agent.mcp and agent.mcp.ready():
218
219
  how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
219
220
  c.print(f"MCP tools: ~{format_tokens(agent.mcp.cost())} tokens of definitions, {how}.")
221
+ if not arg:
222
+ print_breakdown(agent)
220
223
  if arg:
221
224
  try:
222
225
  apply_context(agent, parse_context(arg), hardware)
@@ -256,6 +259,27 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
256
259
  apply_context(agent, size, hardware)
257
260
 
258
261
 
262
+ def print_breakdown(agent: Agent) -> None:
263
+ """What uses the context, by category."""
264
+ parts = [p for p in context_tools.breakdown(agent) if p.tokens]
265
+ total = sum(p.tokens for p in parts) or 1
266
+ table = Table(title="What uses the context (estimates)", title_justify="left", header_style="bold")
267
+ table.add_column("Part")
268
+ table.add_column("Tokens", justify="right")
269
+ table.add_column("", no_wrap=True)
270
+ table.add_column("", style="dim")
271
+ for part in parts:
272
+ share = part.tokens / total
273
+ bar = "█" * max(1, round(share * 20)) + f" {share:.0%}"
274
+ table.add_row(escape(part.name), format_tokens(part.tokens), bar, escape(part.detail))
275
+ agent.console.print(table)
276
+ if agent.pruned or agent.compacted:
277
+ agent.console.print(
278
+ f"[dim]This session: old tool output removed {agent.pruned} time(s), conversation summarized "
279
+ f"{agent.compacted} time(s).[/]"
280
+ )
281
+
282
+
259
283
  def sandbox_command(agent: Agent, arg: str) -> None:
260
284
  c, sandbox = agent.console, agent.sandbox
261
285
  if sandbox is None:
lcode/tools.py CHANGED
@@ -23,6 +23,7 @@ from rich.syntax import Syntax
23
23
  from rich.text import Text
24
24
 
25
25
  from lcode import web
26
+ from lcode.context import shorten
26
27
  from lcode.permissions import bash_key, is_read_only
27
28
  from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
28
29
  from lcode.sandbox import SandboxError
@@ -343,6 +344,11 @@ class Toolbox:
343
344
  skills = self.agent.extensions().skills.values()
344
345
  if any(p == s.folder or s.folder in p.parents for s in skills):
345
346
  return p
347
+ if read and root is not None:
348
+ from lcode.context import outputs_dir
349
+
350
+ if outputs_dir().resolve() in p.parents: # full output that was cut short
351
+ return p
346
352
  if root is not None and p != root and root not in p.parents:
347
353
  raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
348
354
  return p
@@ -469,7 +475,7 @@ class Toolbox:
469
475
  if result.startswith("Error:"):
470
476
  return result
471
477
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s) from {state.name}", style="dim"))
472
- return truncate(result)
478
+ return shorten(result, MAX_TOOL_OUTPUT, f"{state.name} result")
473
479
 
474
480
  # -- read-only
475
481
  def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
@@ -743,7 +749,7 @@ class Toolbox:
743
749
  "with /sandbox network on]"
744
750
  )
745
751
  self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
746
- return truncate("".join(out)) + status + f"\n[exit code: {code}]"
752
+ return shorten("".join(out), MAX_TOOL_OUTPUT, "output") + status + f"\n[exit code: {code}]"
747
753
 
748
754
  # -- web
749
755
  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.11.1
3
+ Version: 0.12.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/
@@ -201,7 +201,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
201
201
 
202
202
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
203
203
 
204
- - [Smarter context for small windows](https://github.com/nasser1941/lcode/issues/46)
205
204
  - [Code intelligence with language servers](https://github.com/nasser1941/lcode/issues/44)
206
205
  - [A repository map and semantic search with local embeddings](https://github.com/nasser1941/lcode/issues/45)
207
206
  - [Hooks and permission rules](https://github.com/nasser1941/lcode/issues/43)
@@ -1,11 +1,12 @@
1
- lcode/__init__.py,sha256=UQAtfDKQe1ahZ-_luedI4_SB9hUVZdwvd8BMWoI-GoI,118
1
+ lcode/__init__.py,sha256=h5xnES1ia_uiIlA2RJp7J_HtBimEoooWRfKT0aVp_Kc,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=5vSYZYFg-5doSF1yx3YRWI8hKSMCxPnSNzJiIwicY_8,40626
4
- lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
3
+ lcode/agent.py,sha256=g4QFYvFkuzWSbdIv9bvw7WCUkLy4EZQ0JSDI45Iv0yM,41793
4
+ lcode/bench.py,sha256=wHRDujY2emLiAcpLU7D25yIdG9c6eZt0wxbe0NW5_c4,39493
5
5
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
6
6
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
7
- lcode/cli.py,sha256=2xSELCKgYyqvJUAwG6P6qimZq6wmZGxwYAxCYfeZ7rk,30509
8
- lcode/config.py,sha256=HwOAz-9LFkH9hOlQwPPG26oIGtOfd0b2Wq2I09WxIAE,8297
7
+ lcode/cli.py,sha256=jD4VOOS--SnnHp5CmQ-zfMukfGxMeuAsRfgNHOcSn84,31068
8
+ lcode/config.py,sha256=2pSPR84e_VWqW6Ec5mYGnjGMdy_azcwpJz173_ENUzQ,8404
9
+ lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
9
10
  lcode/extensions.py,sha256=1MkCmfvQvcJt1XaqIEOYUruCTsrfQVI5TXOTWBkkJtI,15882
10
11
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
11
12
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
@@ -16,11 +17,11 @@ lcode/ollama.py,sha256=0f2rkrm3_5Xcnuyvaxcfx5SeYVyRvHjiROXryHKmRUw,7085
16
17
  lcode/permissions.py,sha256=m8YNtCladVAmXB20ca8AwyfEOzHKS557B0SOvx1oHeM,4528
17
18
  lcode/planning.py,sha256=Z30K2tbqoy2XimKwth1lYYz7OVaQUXGdDvBgops7pGE,6076
18
19
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
19
- lcode/repl.py,sha256=FnyYpRuvpqmIVWQbMSvagrFBvFcLjIAedFeF32u5Vp4,43612
20
+ lcode/repl.py,sha256=Dnm4H0FGbD-LbXUYhF13tL4WTTVf_rzDIuj-Gse9vdU,44647
20
21
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
21
22
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
22
23
  lcode/subagents.py,sha256=ILGFwdFWfwKqExl2D5h_AArqlOris2N0aqh3Ccj895Q,28295
23
- lcode/tools.py,sha256=Af2pbx8KIR4uLaWk2opbzMY8ZFQ22mGcu8FnRbX9DsM,35486
24
+ lcode/tools.py,sha256=42rho60PWJuQeqjEna459nwqOW_2VndiCIKPwgmpTvo,35787
24
25
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
25
26
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
26
27
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -38,8 +39,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
38
39
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
39
40
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
40
41
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
41
- lcode_cli-0.11.1.dist-info/METADATA,sha256=xcrLvH_QYkYUWKgRMrVoBjOTQP_CPDSlp8ttCu6e9O8,11549
42
- lcode_cli-0.11.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
43
- lcode_cli-0.11.1.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
44
- lcode_cli-0.11.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
45
- lcode_cli-0.11.1.dist-info/RECORD,,
42
+ lcode_cli-0.12.0.dist-info/METADATA,sha256=rjhKfHrINzf0qVIeQ7PL8fP3cMeGJkgNTY-63zEhB8Q,11464
43
+ lcode_cli-0.12.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
44
+ lcode_cli-0.12.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
45
+ lcode_cli-0.12.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
46
+ lcode_cli-0.12.0.dist-info/RECORD,,