lcode-cli 0.8.0__py3-none-any.whl → 0.9.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 +12 -2
- lcode/cli.py +4 -1
- lcode/config.py +2 -2
- lcode/mcp/commands.py +2 -2
- lcode/memory.py +1 -1
- lcode/planning.py +138 -0
- lcode/repl.py +18 -2
- lcode/tools.py +31 -1
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.9.0.dist-info}/METADATA +4 -2
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.9.0.dist-info}/RECORD +14 -13
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.9.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.9.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.9.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -21,7 +21,7 @@ from rich.markup import escape
|
|
|
21
21
|
from rich.panel import Panel
|
|
22
22
|
from rich.text import Text
|
|
23
23
|
|
|
24
|
-
from lcode import catalog, limits, sessions, subagents, vision, web
|
|
24
|
+
from lcode import catalog, limits, planning, sessions, subagents, vision, web
|
|
25
25
|
from lcode import memory as memory_notes
|
|
26
26
|
from lcode.checkpoints import Checkpoints
|
|
27
27
|
from lcode.config import format_tokens
|
|
@@ -32,6 +32,7 @@ from lcode.render import MarkdownStreamer
|
|
|
32
32
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
33
33
|
from lcode.tools import (
|
|
34
34
|
MEMORY_SCHEMA,
|
|
35
|
+
PRESENT_PLAN_SCHEMA,
|
|
35
36
|
SCHEMAS,
|
|
36
37
|
VIEW_IMAGE_SCHEMA,
|
|
37
38
|
WEB_FETCH_SCHEMA,
|
|
@@ -198,6 +199,7 @@ class Agent:
|
|
|
198
199
|
self._memory: memory_notes.Memory | None = None
|
|
199
200
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
200
201
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
202
|
+
self.plan = "" # the plan the user approved; kept through compaction
|
|
201
203
|
# Set on subagents (see lcode.subagents):
|
|
202
204
|
self.allowed_tools: set[str] | None = None # None: every tool
|
|
203
205
|
self.read_only = False # only read-only shell commands
|
|
@@ -259,6 +261,9 @@ class Agent:
|
|
|
259
261
|
assert self._agent_types is not None
|
|
260
262
|
return self._agent_types[2]
|
|
261
263
|
|
|
264
|
+
def planning(self) -> bool:
|
|
265
|
+
return self.perms.mode == "plan"
|
|
266
|
+
|
|
262
267
|
def tool_names(self) -> set[str]:
|
|
263
268
|
return {s["function"]["name"] for s in self.tool_schemas()}
|
|
264
269
|
|
|
@@ -283,6 +288,8 @@ class Agent:
|
|
|
283
288
|
schemas.append(MEMORY_SCHEMA)
|
|
284
289
|
if self.settings.subagents:
|
|
285
290
|
schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
|
|
291
|
+
if self.planning():
|
|
292
|
+
schemas.append(PRESENT_PLAN_SCHEMA)
|
|
286
293
|
if self.mcp:
|
|
287
294
|
schemas += self.mcp.schemas(self.settings.context)
|
|
288
295
|
if self.allowed_tools is not None:
|
|
@@ -638,7 +645,8 @@ class Agent:
|
|
|
638
645
|
|
|
639
646
|
def _run_turn(self, user_text: str, max_steps: int = MAX_STEPS_PER_TURN) -> None:
|
|
640
647
|
self.prepare_mcp()
|
|
641
|
-
self.
|
|
648
|
+
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
649
|
+
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
642
650
|
malformed = 0
|
|
643
651
|
for _ in range(max_steps):
|
|
644
652
|
if self.cancel is not None and self.cancel.is_set():
|
|
@@ -777,6 +785,8 @@ class Agent:
|
|
|
777
785
|
{"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
|
|
778
786
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
779
787
|
]
|
|
788
|
+
if self.plan: # the approved plan stays word for word
|
|
789
|
+
self.messages[1]["content"] += f"\n\n[The plan the user approved; keep following it]\n\n{self.plan}"
|
|
780
790
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
781
791
|
self.reflected = len(self.messages)
|
|
782
792
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
lcode/cli.py
CHANGED
|
@@ -519,7 +519,9 @@ def cmd_chat(args) -> None:
|
|
|
519
519
|
context, note = choose_context(ollama, model, spec, requested_ctx, hw)
|
|
520
520
|
if note:
|
|
521
521
|
console.print(f"[yellow]Context {format_tokens(context)}: {note}[/]")
|
|
522
|
-
mode =
|
|
522
|
+
mode = (
|
|
523
|
+
"yolo" if args.yolo else ("auto-edit" if args.auto_edit else ("plan" if args.plan else cfg["permission_mode"]))
|
|
524
|
+
)
|
|
523
525
|
num_batch = cfg["num_batch"]
|
|
524
526
|
if num_batch is None and spec and spec.num_batch:
|
|
525
527
|
if model == spec.local_name:
|
|
@@ -589,6 +591,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
589
591
|
metavar="SESSION",
|
|
590
592
|
help="resume a saved session: pick from a list, or give its number, name or id",
|
|
591
593
|
)
|
|
594
|
+
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
592
595
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
593
596
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
594
597
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
lcode/config.py
CHANGED
|
@@ -21,7 +21,7 @@ else:
|
|
|
21
21
|
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "lcode"
|
|
22
22
|
|
|
23
23
|
DEFAULT_MODEL = "qwen3.6-35b"
|
|
24
|
-
PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
24
|
+
PERMISSION_MODES = ("ask", "plan", "auto-edit", "yolo") # Shift+Tab cycles in this order
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
@@ -35,7 +35,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
35
35
|
"num_batch": (None, int, "prompt batch size; larger reads prompts faster but needs more VRAM"),
|
|
36
36
|
"keep_alive": ("30m", str, "how long Ollama keeps the model loaded after the last request"),
|
|
37
37
|
"ollama_host": ("http://localhost:11434", str, "Ollama server URL"),
|
|
38
|
-
"permission_mode": ("ask", str, "ask | auto-edit | yolo"),
|
|
38
|
+
"permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
|
|
39
39
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
40
40
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
41
41
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
lcode/mcp/commands.py
CHANGED
|
@@ -216,9 +216,9 @@ def _ask(spec: catalog.Input, console: Console) -> str | None:
|
|
|
216
216
|
source = " ".join(spec.from_command)
|
|
217
217
|
if Confirm.ask(f" Use the token from `{source}`?", default=True, console=console):
|
|
218
218
|
return suggestion
|
|
219
|
-
default = f" [{spec.default}]" if spec.default else ""
|
|
219
|
+
default = f" \\[{escape(spec.default)}]" if spec.default else "" # escaped, or rich takes it for markup
|
|
220
220
|
while True:
|
|
221
|
-
value = console.input(f" {spec.prompt}{default}: ", password=spec.secret).strip()
|
|
221
|
+
value = console.input(f" {escape(spec.prompt)}{default}: ", password=spec.secret).strip()
|
|
222
222
|
value = value or spec.default or ""
|
|
223
223
|
if value or spec.optional:
|
|
224
224
|
return value or None
|
lcode/memory.py
CHANGED
|
@@ -507,7 +507,7 @@ def reflect(agent: Agent) -> None:
|
|
|
507
507
|
where = "every repository" if note.scope == "user" else "this repository"
|
|
508
508
|
update = memory.find(note.name, note.scope)
|
|
509
509
|
rows.append(
|
|
510
|
-
f"[cyan]{i}[/] [{note.type}] {escape(note.description)} [dim]({where}"
|
|
510
|
+
f"[cyan]{i}[/] \\[{note.type}] {escape(note.description)} [dim]({where}"
|
|
511
511
|
f"{', updates ' + escape(update.name) if update else ''})[/]"
|
|
512
512
|
+ (f"\n [dim]{escape(note.details)}[/]" if note.details else "")
|
|
513
513
|
)
|
lcode/planning.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Plan mode: agree on an approach before anything changes.
|
|
2
|
+
|
|
3
|
+
In the `plan` permission mode the model can read, search, run read-only commands, use the web and
|
|
4
|
+
read-only subagents, but can't change anything. It ends by presenting a plan with the
|
|
5
|
+
present_plan tool. The user approves it (lcode switches to `ask` or `auto-edit`, fills the todo
|
|
6
|
+
list from the plan's steps and keeps the plan through compaction), edits it, saves it to
|
|
7
|
+
`.lcode/plans/`, or sends it back with feedback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from rich.markdown import Markdown
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
|
|
25
|
+
from lcode.checkpoints import work_tree_for
|
|
26
|
+
from lcode.memory import slugify
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lcode.agent import Agent
|
|
30
|
+
|
|
31
|
+
# Tools that can't change anything (bash is limited to read-only commands, agent to read-only types).
|
|
32
|
+
PLAN_MODE_TOOLS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
|
|
35
|
+
"agent", "memory", "present_plan", "mcp_find_tools",
|
|
36
|
+
}
|
|
37
|
+
) # fmt: skip
|
|
38
|
+
|
|
39
|
+
NOTE = """
|
|
40
|
+
|
|
41
|
+
[lcode] Plan mode is on: you can read, search, run read-only commands, use the web and read-only subagents, but you can't change anything yet. Work out how to do this, then call present_plan with: numbered steps, the files you'll change and how, the risks, and how you'll verify the result. Ask the user only about decisions that are genuinely theirs."""
|
|
42
|
+
|
|
43
|
+
BLOCKED = (
|
|
44
|
+
"Plan mode is on, so nothing can be changed yet. Finish working out the plan and present it with "
|
|
45
|
+
"present_plan; once the user approves it, you can make the changes."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
CHOICES = (
|
|
49
|
+
" [bold]\\[y][/] run it, asking before changes · [bold]\\[a][/] run it, edits without asking (auto-edit)\n"
|
|
50
|
+
" [bold]\\[e][/] edit the plan · [bold]\\[s][/] save it to .lcode/plans/ · [bold]\\[n][/] keep planning (+ what to change)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def steps(plan: str, limit: int = 20) -> list[str]:
|
|
55
|
+
"""The plan's numbered steps (top level only), for the todo list."""
|
|
56
|
+
found = []
|
|
57
|
+
for line in plan.splitlines():
|
|
58
|
+
m = re.match(r"^ {0,3}\d+[.)]\s+(.+)", line)
|
|
59
|
+
if m:
|
|
60
|
+
# Plain text for the todo list: no bold, code or emphasis marks (underscores in names stay).
|
|
61
|
+
text = m.group(1).replace("**", "").replace("__", "").replace("`", "")
|
|
62
|
+
text = re.sub(r"(?<!\w)[*_]([^*_]+)[*_](?!\w)", r"\1", text).strip().rstrip(":").strip()
|
|
63
|
+
found.append(text if len(text) <= 120 else text[:119].rstrip() + "…")
|
|
64
|
+
return found[:limit]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def edit_text(text: str) -> str:
|
|
68
|
+
"""Let the user edit `text` in their editor; returns the result."""
|
|
69
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("nano" if shutil.which("nano") else "vi")
|
|
70
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
|
|
71
|
+
f.write(text)
|
|
72
|
+
path = Path(f.name)
|
|
73
|
+
try:
|
|
74
|
+
subprocess.call([*shlex.split(editor), str(path)])
|
|
75
|
+
return path.read_text()
|
|
76
|
+
finally:
|
|
77
|
+
path.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save(agent: Agent, title: str, plan: str) -> Path:
|
|
81
|
+
folder = work_tree_for(agent.cwd) / ".lcode" / "plans"
|
|
82
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
path = folder / f"{slugify(title) or 'plan'}.md"
|
|
84
|
+
path.write_text(f"# {title}\n\n{plan.strip()}\n")
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def present(agent: Agent, title: str, plan: str) -> str:
|
|
89
|
+
"""Show the plan and let the user decide. Returns the tool result for the model."""
|
|
90
|
+
c = agent.console
|
|
91
|
+
title = " ".join(title.split()) or "Plan"
|
|
92
|
+
plan = plan.strip()
|
|
93
|
+
if not plan:
|
|
94
|
+
return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work."
|
|
95
|
+
edited = False
|
|
96
|
+
while True:
|
|
97
|
+
c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan"))
|
|
98
|
+
if not agent.interactive:
|
|
99
|
+
return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
|
|
100
|
+
c.print(CHOICES)
|
|
101
|
+
try:
|
|
102
|
+
answer = input(" > ").strip()
|
|
103
|
+
except EOFError:
|
|
104
|
+
return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
|
|
105
|
+
word = answer.lower()
|
|
106
|
+
if word in ("", "y", "yes"):
|
|
107
|
+
return approve(agent, title, plan, "ask", edited)
|
|
108
|
+
if word in ("a", "auto", "auto-edit"):
|
|
109
|
+
return approve(agent, title, plan, "auto-edit", edited)
|
|
110
|
+
if word in ("e", "edit"):
|
|
111
|
+
new = edit_text(plan).strip()
|
|
112
|
+
if new and new != plan:
|
|
113
|
+
plan, edited = new, True
|
|
114
|
+
continue
|
|
115
|
+
if word in ("s", "save"):
|
|
116
|
+
path = save(agent, title, plan)
|
|
117
|
+
c.print(f"[green]Saved to {escape(agent.tools.rel(path))}[/]")
|
|
118
|
+
continue
|
|
119
|
+
feedback = re.sub(r"^(no|n)\b[\s,:-]*", "", answer, flags=re.I).strip()
|
|
120
|
+
if feedback:
|
|
121
|
+
return f"The user wants changes to the plan: {feedback}\nStay in plan mode: revise the plan and present it again."
|
|
122
|
+
return "The user didn't approve the plan. Ask them what they'd like to change."
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def approve(agent: Agent, title: str, plan: str, mode: str, edited: bool) -> str:
|
|
126
|
+
agent.perms.mode = mode
|
|
127
|
+
agent.plan = f"# {title}\n\n{plan}"
|
|
128
|
+
todo = steps(plan)
|
|
129
|
+
if todo:
|
|
130
|
+
agent.tools.todos = [{"content": step, "status": "pending"} for step in todo]
|
|
131
|
+
agent.tools.show_todos()
|
|
132
|
+
how = "file edits no longer ask (auto-edit)" if mode == "auto-edit" else "changes ask for approval as usual"
|
|
133
|
+
agent.console.print(f"[green]Plan approved.[/] Mode: {mode} — {how}.")
|
|
134
|
+
result = f"The user approved the plan, and plan mode is off ({mode} mode: {how}). Carry it out now, step by step"
|
|
135
|
+
result += ", keeping the todo list up to date." if todo else "."
|
|
136
|
+
if edited:
|
|
137
|
+
result += f"\nThe user edited the plan first. This is the approved version:\n\n{plan}"
|
|
138
|
+
return result
|
lcode/repl.py
CHANGED
|
@@ -18,6 +18,7 @@ from prompt_toolkit.formatted_text import HTML
|
|
|
18
18
|
from prompt_toolkit.history import FileHistory
|
|
19
19
|
from prompt_toolkit.key_binding import KeyBindings
|
|
20
20
|
from rich.console import Group
|
|
21
|
+
from rich.markdown import Markdown
|
|
21
22
|
from rich.markup import escape
|
|
22
23
|
from rich.panel import Panel
|
|
23
24
|
from rich.rule import Rule
|
|
@@ -55,7 +56,8 @@ COMMANDS = {
|
|
|
55
56
|
"/models": "List models and how they fit this machine",
|
|
56
57
|
"/think": "Toggle model reasoning on/off",
|
|
57
58
|
"/verbose": "Toggle showing the model's reasoning text",
|
|
58
|
-
"/
|
|
59
|
+
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
|
|
60
|
+
"/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
|
|
59
61
|
"/cd": "Change the working directory",
|
|
60
62
|
"/todos": "Show the current todo list",
|
|
61
63
|
"/exit": "Quit",
|
|
@@ -118,7 +120,7 @@ def build_session(agent: Agent) -> PromptSession:
|
|
|
118
120
|
def toolbar():
|
|
119
121
|
s = agent.settings
|
|
120
122
|
pct = 100 * agent.ctx_used / s.context
|
|
121
|
-
color = {"ask": "ansigreen", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
|
|
123
|
+
color = {"ask": "ansigreen", "plan": "ansicyan", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
|
|
122
124
|
return HTML(
|
|
123
125
|
f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
|
|
124
126
|
f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
|
|
@@ -772,6 +774,20 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
772
774
|
elif cmd == "/verbose":
|
|
773
775
|
s.show_thinking = not s.show_thinking
|
|
774
776
|
c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
|
|
777
|
+
elif cmd == "/plan":
|
|
778
|
+
if not arg:
|
|
779
|
+
if agent.plan:
|
|
780
|
+
c.print(Panel(Markdown(agent.plan), title="Approved plan", title_align="left", border_style="cyan"))
|
|
781
|
+
else:
|
|
782
|
+
c.print(
|
|
783
|
+
"No approved plan in this session. [bold]/plan <request>[/] works out a plan first, without "
|
|
784
|
+
"changing anything (or Shift+Tab to plan mode)."
|
|
785
|
+
)
|
|
786
|
+
return True
|
|
787
|
+
agent.perms.mode = "plan"
|
|
788
|
+
c.print("[cyan]Plan mode:[/] the model can look around but not change anything until you approve its plan.")
|
|
789
|
+
c.print(Rule(style="dim"))
|
|
790
|
+
run_safely(agent, arg)
|
|
775
791
|
elif cmd == "/mode":
|
|
776
792
|
if arg in PERMISSION_MODES:
|
|
777
793
|
agent.perms.mode = arg
|
lcode/tools.py
CHANGED
|
@@ -24,6 +24,7 @@ from rich.text import Text
|
|
|
24
24
|
|
|
25
25
|
from lcode import web
|
|
26
26
|
from lcode.permissions import bash_key, is_read_only
|
|
27
|
+
from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
|
|
27
28
|
from lcode.sandbox import SandboxError
|
|
28
29
|
from lcode.vision import is_image
|
|
29
30
|
|
|
@@ -198,8 +199,22 @@ MEMORY_SCHEMA = _fn(
|
|
|
198
199
|
},
|
|
199
200
|
["action"],
|
|
200
201
|
)
|
|
202
|
+
PRESENT_PLAN_SCHEMA = _fn(
|
|
203
|
+
"present_plan",
|
|
204
|
+
"Plan mode: show the user your plan and ask for approval. Once approved, plan mode ends and you carry it out.",
|
|
205
|
+
{
|
|
206
|
+
"title": {"type": "string", "description": "A short name for the plan, e.g. 'Add retries to webhooks'"},
|
|
207
|
+
"plan": {
|
|
208
|
+
"type": "string",
|
|
209
|
+
"description": "Markdown: numbered steps, the files you'll change and how, the risks, and how you'll "
|
|
210
|
+
"verify the result",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
["title", "plan"],
|
|
214
|
+
)
|
|
201
215
|
TOOL_NAMES = {
|
|
202
|
-
s["function"]["name"]
|
|
216
|
+
s["function"]["name"]
|
|
217
|
+
for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA, PRESENT_PLAN_SCHEMA]
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
|
|
@@ -337,6 +352,10 @@ class Toolbox:
|
|
|
337
352
|
allowed = self.agent.allowed_tools
|
|
338
353
|
if allowed is not None and name not in allowed:
|
|
339
354
|
return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
|
|
355
|
+
if self.agent.planning() and name not in PLAN_MODE_TOOLS:
|
|
356
|
+
return f"Error: {BLOCKED}"
|
|
357
|
+
if name == "present_plan" and not self.agent.planning():
|
|
358
|
+
return "Error: plan mode is off, so there's nothing to present: carry on with the work."
|
|
340
359
|
if self.agent.mcp and self.agent.mcp.owns(name):
|
|
341
360
|
return self._mcp(name, args)
|
|
342
361
|
fn = getattr(self, f"t_{name}", None)
|
|
@@ -376,8 +395,17 @@ class Toolbox:
|
|
|
376
395
|
raise ToolError("subagents are turned off")
|
|
377
396
|
from lcode import subagents
|
|
378
397
|
|
|
398
|
+
kind = self.agent.agent_types().get(type)
|
|
399
|
+
if self.agent.planning() and kind and not kind.read_only:
|
|
400
|
+
raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
|
|
379
401
|
return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
|
|
380
402
|
|
|
403
|
+
# -- plan mode
|
|
404
|
+
def t_present_plan(self, title: str, plan: str) -> str:
|
|
405
|
+
from lcode.planning import present
|
|
406
|
+
|
|
407
|
+
return present(self.agent, title, plan)
|
|
408
|
+
|
|
381
409
|
# -- memory
|
|
382
410
|
def t_memory(
|
|
383
411
|
self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
|
|
@@ -605,6 +633,8 @@ class Toolbox:
|
|
|
605
633
|
# -- shell
|
|
606
634
|
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
607
635
|
sandbox = self.agent.sandbox
|
|
636
|
+
if self.agent.planning() and not is_read_only(command):
|
|
637
|
+
raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
|
|
608
638
|
if self.agent.read_only and not is_read_only(command):
|
|
609
639
|
raise ToolError(
|
|
610
640
|
"you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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/
|
|
@@ -80,6 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
80
80
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
81
81
|
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
82
82
|
Podman) that only sees your project, without network access unless you allow it.
|
|
83
|
+
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
84
|
+
you approve, edit or send it back before anything changes.
|
|
83
85
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
84
86
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
85
87
|
- **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
|
|
@@ -195,7 +197,7 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
195
197
|
|
|
196
198
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
197
199
|
|
|
198
|
-
- [
|
|
200
|
+
- [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
|
|
199
201
|
- [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
|
|
200
202
|
- Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
|
|
201
203
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=nFv9AKdtRRoIhmSP_gIiY5ftUlCFh_s-FQeBjbqj6fI,117
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=fP52AvWe7WfTXmzbM9BH-_rIUtdEf2Ix1iBspeqcTr4,39130
|
|
4
4
|
lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
|
|
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=Bh9wdAe5fZPzfKUdsbebpMObxqKxLZIDsI3OldhddO8,29610
|
|
8
|
+
lcode/config.py,sha256=Wle_Gwv-FWQmD9jBt8jiHNtmdn3O5bOgkrZ6tbD7_9o,7947
|
|
9
9
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
10
10
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
11
|
-
lcode/memory.py,sha256=
|
|
11
|
+
lcode/memory.py,sha256=SWB_xt_fdL2hX1mcUqK0drU7laZmg1CqIs0IIAehnCo,22361
|
|
12
12
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
13
13
|
lcode/ollama.py,sha256=0f2rkrm3_5Xcnuyvaxcfx5SeYVyRvHjiROXryHKmRUw,7085
|
|
14
14
|
lcode/permissions.py,sha256=m8YNtCladVAmXB20ca8AwyfEOzHKS557B0SOvx1oHeM,4528
|
|
15
|
+
lcode/planning.py,sha256=C7_JyArIywPygU9DdfaoN2EfgA6JGNnKafuIilkOzGw,6067
|
|
15
16
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
16
|
-
lcode/repl.py,sha256=
|
|
17
|
+
lcode/repl.py,sha256=K04EpyIxyrP6I3oPh9tp8xqoIubplqHRXvWbqFdOKks,40356
|
|
17
18
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
18
19
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
19
20
|
lcode/subagents.py,sha256=L5U-USz2fnGOx3Z28_sOA6btWmMKSGXVbFKQYcrZSDM,28147
|
|
20
|
-
lcode/tools.py,sha256=
|
|
21
|
+
lcode/tools.py,sha256=VNhNkC43x2l20kqv06cbDXmdJHugDncFX0AIUfAr3CE,34820
|
|
21
22
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
22
23
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
23
24
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -25,14 +26,14 @@ lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
|
|
|
25
26
|
lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
|
|
26
27
|
lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
|
|
27
28
|
lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
|
|
28
|
-
lcode/mcp/commands.py,sha256=
|
|
29
|
+
lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
|
|
29
30
|
lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
|
|
30
31
|
lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
|
|
31
32
|
lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
|
|
32
33
|
lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
|
|
33
34
|
lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
34
|
-
lcode_cli-0.
|
|
35
|
-
lcode_cli-0.
|
|
36
|
-
lcode_cli-0.
|
|
37
|
-
lcode_cli-0.
|
|
38
|
-
lcode_cli-0.
|
|
35
|
+
lcode_cli-0.9.0.dist-info/METADATA,sha256=DW7hETwyh_0QCwMacZY-ofQbFPMPZTgChvrxjXDTb-8,11379
|
|
36
|
+
lcode_cli-0.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
37
|
+
lcode_cli-0.9.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
|
|
38
|
+
lcode_cli-0.9.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
39
|
+
lcode_cli-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|