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 +1 -1
- lcode/agent.py +22 -3
- lcode/bench.py +44 -11
- lcode/cli.py +15 -2
- lcode/config.py +1 -0
- lcode/context.py +243 -0
- lcode/repl.py +24 -0
- lcode/tools.py +8 -2
- {lcode_cli-0.11.1.dist-info → lcode_cli-0.12.0.dist-info}/METADATA +1 -2
- {lcode_cli-0.11.1.dist-info → lcode_cli-0.12.0.dist-info}/RECORD +13 -12
- {lcode_cli-0.11.1.dist-info → lcode_cli-0.12.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.11.1.dist-info → lcode_cli-0.12.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.11.1.dist-info → lcode_cli-0.12.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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
|
|
779
|
-
|
|
780
|
-
|
|
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,
|
|
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
|
|
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=
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
|
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
|
|
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.
|
|
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=
|
|
1
|
+
lcode/__init__.py,sha256=h5xnES1ia_uiIlA2RJp7J_HtBimEoooWRfKT0aVp_Kc,118
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
4
|
-
lcode/bench.py,sha256=
|
|
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=
|
|
8
|
-
lcode/config.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
42
|
-
lcode_cli-0.
|
|
43
|
-
lcode_cli-0.
|
|
44
|
-
lcode_cli-0.
|
|
45
|
-
lcode_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|