lcode-cli 0.8.0__py3-none-any.whl → 0.10.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 +36 -4
- lcode/cli.py +23 -1
- lcode/config.py +8 -2
- lcode/extensions.py +405 -0
- lcode/frontmatter.py +62 -0
- lcode/mcp/commands.py +2 -2
- lcode/memory.py +2 -14
- lcode/planning.py +138 -0
- lcode/repl.py +80 -4
- lcode/subagents.py +7 -7
- lcode/tools.py +50 -6
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.10.0.dist-info}/METADATA +5 -2
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.10.0.dist-info}/RECORD +17 -14
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.10.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.10.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.8.0.dist-info → lcode_cli-0.10.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, extensions, 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,
|
|
@@ -77,7 +78,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
77
78
|
|
|
78
79
|
# Top-level layout of the working directory
|
|
79
80
|
{tree}
|
|
80
|
-
{web}{agents}{project}{notes}"""
|
|
81
|
+
{web}{agents}{skills}{project}{notes}"""
|
|
81
82
|
|
|
82
83
|
WEB_PROMPT = """
|
|
83
84
|
# Web access
|
|
@@ -170,6 +171,8 @@ class Settings:
|
|
|
170
171
|
vision_model: str = "auto" # auto, off or an Ollama model that can see images
|
|
171
172
|
memory: str = "off" # off, ask or auto: notes that carry over to later sessions (lcode.memory)
|
|
172
173
|
subagents: bool = False # the agent tool (lcode.subagents)
|
|
174
|
+
trust_project: bool = False # use the repository's own commands, skills and agents (lcode.extensions)
|
|
175
|
+
skills: str = "off" # all, lcode (only lcode's own skill folders) or off
|
|
173
176
|
max_parallel_agents: int = 1
|
|
174
177
|
|
|
175
178
|
|
|
@@ -198,6 +201,9 @@ class Agent:
|
|
|
198
201
|
self._memory: memory_notes.Memory | None = None
|
|
199
202
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
200
203
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
204
|
+
self.plan = "" # the plan the user approved; kept through compaction
|
|
205
|
+
self._extensions: tuple[Path, extensions.Extensions] | None = None
|
|
206
|
+
self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
|
|
201
207
|
# Set on subagents (see lcode.subagents):
|
|
202
208
|
self.allowed_tools: set[str] | None = None # None: every tool
|
|
203
209
|
self.read_only = False # only read-only shell commands
|
|
@@ -234,8 +240,15 @@ class Agent:
|
|
|
234
240
|
notes=self.memory().prompt(self.settings.context) if self.settings.memory != "off" else "",
|
|
235
241
|
web=self.web_prompt(),
|
|
236
242
|
agents=self.agents_prompt(),
|
|
243
|
+
skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
|
|
237
244
|
)
|
|
238
245
|
|
|
246
|
+
def extensions(self) -> extensions.Extensions:
|
|
247
|
+
"""Custom commands and skills for the current folder (a repository's only once approved)."""
|
|
248
|
+
if self._extensions is None or self._extensions[0] != self.cwd:
|
|
249
|
+
self._extensions = (self.cwd, extensions.load(self.cwd, self.settings.trust_project, self.settings.skills))
|
|
250
|
+
return self._extensions[1]
|
|
251
|
+
|
|
239
252
|
def agents_prompt(self) -> str:
|
|
240
253
|
if not self.settings.subagents:
|
|
241
254
|
return ""
|
|
@@ -250,7 +263,7 @@ class Agent:
|
|
|
250
263
|
def agent_types(self) -> dict[str, subagents.AgentType]:
|
|
251
264
|
"""Built-in and custom agent types for the current folder."""
|
|
252
265
|
if self._agent_types is None or self._agent_types[0] != self.cwd:
|
|
253
|
-
types, problems = subagents.load_types(self.cwd)
|
|
266
|
+
types, problems = subagents.load_types(self.cwd, self.settings.trust_project)
|
|
254
267
|
self._agent_types = (self.cwd, types, problems)
|
|
255
268
|
return self._agent_types[1]
|
|
256
269
|
|
|
@@ -259,6 +272,9 @@ class Agent:
|
|
|
259
272
|
assert self._agent_types is not None
|
|
260
273
|
return self._agent_types[2]
|
|
261
274
|
|
|
275
|
+
def planning(self) -> bool:
|
|
276
|
+
return self.perms.mode == "plan"
|
|
277
|
+
|
|
262
278
|
def tool_names(self) -> set[str]:
|
|
263
279
|
return {s["function"]["name"] for s in self.tool_schemas()}
|
|
264
280
|
|
|
@@ -283,6 +299,10 @@ class Agent:
|
|
|
283
299
|
schemas.append(MEMORY_SCHEMA)
|
|
284
300
|
if self.settings.subagents:
|
|
285
301
|
schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
|
|
302
|
+
if self.planning():
|
|
303
|
+
schemas.append(PRESENT_PLAN_SCHEMA)
|
|
304
|
+
if self.extensions().skills:
|
|
305
|
+
schemas.append(extensions.schema(self.extensions().skills))
|
|
286
306
|
if self.mcp:
|
|
287
307
|
schemas += self.mcp.schemas(self.settings.context)
|
|
288
308
|
if self.allowed_tools is not None:
|
|
@@ -402,6 +422,7 @@ class Agent:
|
|
|
402
422
|
|
|
403
423
|
def new_session(self) -> None:
|
|
404
424
|
self.reset()
|
|
425
|
+
self.skills_loaded.clear()
|
|
405
426
|
self.session_id = self.new_session_id()
|
|
406
427
|
self.session_name = ""
|
|
407
428
|
self.session_title = ""
|
|
@@ -599,6 +620,8 @@ class Agent:
|
|
|
599
620
|
return f"list_dir({args.get('path', '.')})"
|
|
600
621
|
if name == "memory":
|
|
601
622
|
return f"memory({' '.join(str(args.get(k) or '') for k in ('action', 'name')).strip()})"
|
|
623
|
+
if name == "skill":
|
|
624
|
+
return f"skill({args.get('name', '')})"
|
|
602
625
|
if name == "agent":
|
|
603
626
|
return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
|
|
604
627
|
if name == "bash":
|
|
@@ -638,7 +661,8 @@ class Agent:
|
|
|
638
661
|
|
|
639
662
|
def _run_turn(self, user_text: str, max_steps: int = MAX_STEPS_PER_TURN) -> None:
|
|
640
663
|
self.prepare_mcp()
|
|
641
|
-
self.
|
|
664
|
+
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
665
|
+
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
642
666
|
malformed = 0
|
|
643
667
|
for _ in range(max_steps):
|
|
644
668
|
if self.cancel is not None and self.cancel.is_set():
|
|
@@ -777,6 +801,14 @@ class Agent:
|
|
|
777
801
|
{"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
|
|
778
802
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
779
803
|
]
|
|
804
|
+
if self.plan: # the approved plan stays word for word
|
|
805
|
+
self.messages[1]["content"] += f"\n\n[The plan the user approved; keep following it]\n\n{self.plan}"
|
|
806
|
+
if self.skills_loaded: # their instructions were in the old messages: load them again when needed
|
|
807
|
+
names = ", ".join(sorted(self.skills_loaded))
|
|
808
|
+
self.messages[1]["content"] += (
|
|
809
|
+
f"\n\n[Skills in use before the summary: {names}. Load them again with the skill tool if you still need them.]"
|
|
810
|
+
)
|
|
811
|
+
self.skills_loaded.clear()
|
|
780
812
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
781
813
|
self.reflected = len(self.messages)
|
|
782
814
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
lcode/cli.py
CHANGED
|
@@ -367,6 +367,20 @@ def cmd_doctor(args) -> None:
|
|
|
367
367
|
line("", f"{n} × {slots[1]} context may not fit in memory here; {slots[0]} would", None)
|
|
368
368
|
for problem in problems:
|
|
369
369
|
line("", problem, None)
|
|
370
|
+
from lcode import extensions
|
|
371
|
+
|
|
372
|
+
project_state = extensions.status(Path.cwd())
|
|
373
|
+
ext = extensions.load(Path.cwd(), include_project=project_state == "approved", skills_from=cfg["skills"])
|
|
374
|
+
found = f"{len(ext.commands)} command(s), {len(ext.skills)} skill(s)"
|
|
375
|
+
note = {
|
|
376
|
+
"none": "",
|
|
377
|
+
"approved": " · this repository's are approved",
|
|
378
|
+
"new": " · this repository brings its own: approve them when lcode asks",
|
|
379
|
+
"changed": " · this repository's changed since you approved them: lcode asks again",
|
|
380
|
+
}[project_state]
|
|
381
|
+
line("Extras", found + note, None if project_state in ("new", "changed") else True)
|
|
382
|
+
for problem in ext.problems:
|
|
383
|
+
line("", problem, None)
|
|
370
384
|
if cfg["memory"] == "off":
|
|
371
385
|
line("Memory", "off (lcode config set memory ask)", True)
|
|
372
386
|
else:
|
|
@@ -519,13 +533,18 @@ def cmd_chat(args) -> None:
|
|
|
519
533
|
context, note = choose_context(ollama, model, spec, requested_ctx, hw)
|
|
520
534
|
if note:
|
|
521
535
|
console.print(f"[yellow]Context {format_tokens(context)}: {note}[/]")
|
|
522
|
-
mode =
|
|
536
|
+
mode = (
|
|
537
|
+
"yolo" if args.yolo else ("auto-edit" if args.auto_edit else ("plan" if args.plan else cfg["permission_mode"]))
|
|
538
|
+
)
|
|
523
539
|
num_batch = cfg["num_batch"]
|
|
524
540
|
if num_batch is None and spec and spec.num_batch:
|
|
525
541
|
if model == spec.local_name:
|
|
526
542
|
num_batch = spec.num_batch
|
|
527
543
|
else: # the tuned batch size assumes the text-only variant; the vision projector needs that VRAM
|
|
528
544
|
console.print(f"[dim]Tip: run `lcode setup {spec.key}` once to create the faster text-only variant.[/]")
|
|
545
|
+
from lcode import extensions
|
|
546
|
+
|
|
547
|
+
trust_project = extensions.trust_project(cwd, console, interactive=not args.prompt)
|
|
529
548
|
settings = Settings(
|
|
530
549
|
model=model,
|
|
531
550
|
context=context,
|
|
@@ -545,6 +564,8 @@ def cmd_chat(args) -> None:
|
|
|
545
564
|
memory="off" if args.no_memory else cfg["memory"],
|
|
546
565
|
subagents=cfg["subagents"],
|
|
547
566
|
max_parallel_agents=cfg["max_parallel_agents"],
|
|
567
|
+
trust_project=trust_project,
|
|
568
|
+
skills=cfg["skills"],
|
|
548
569
|
)
|
|
549
570
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
550
571
|
agent.interactive = not args.prompt
|
|
@@ -589,6 +610,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
589
610
|
metavar="SESSION",
|
|
590
611
|
help="resume a saved session: pick from a list, or give its number, name or id",
|
|
591
612
|
)
|
|
613
|
+
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
592
614
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
593
615
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
594
616
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
lcode/config.py
CHANGED
|
@@ -21,12 +21,13 @@ 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")
|
|
28
28
|
SANDBOX_ENGINES = ("off", "docker", "podman")
|
|
29
29
|
MEMORY_MODES = ("off", "ask", "auto")
|
|
30
|
+
SKILL_SOURCES = ("all", "lcode", "off")
|
|
30
31
|
|
|
31
32
|
# key -> (default, type, help)
|
|
32
33
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -35,7 +36,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
35
36
|
"num_batch": (None, int, "prompt batch size; larger reads prompts faster but needs more VRAM"),
|
|
36
37
|
"keep_alive": ("30m", str, "how long Ollama keeps the model loaded after the last request"),
|
|
37
38
|
"ollama_host": ("http://localhost:11434", str, "Ollama server URL"),
|
|
38
|
-
"permission_mode": ("ask", str, "ask | auto-edit | yolo"),
|
|
39
|
+
"permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
|
|
39
40
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
40
41
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
41
42
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
@@ -47,6 +48,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
47
48
|
"vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
|
|
48
49
|
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
49
50
|
"memory": ("ask", str, "notes that carry over to later sessions: off | ask (confirm each) | auto"),
|
|
51
|
+
"skills": ("all", str, "skills to offer the model: all (also other agents' folders) | lcode | off"),
|
|
50
52
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
51
53
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
52
54
|
}
|
|
@@ -120,6 +122,10 @@ def coerce(key: str, value: object) -> object:
|
|
|
120
122
|
if value not in MEMORY_MODES:
|
|
121
123
|
raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
|
|
122
124
|
return value
|
|
125
|
+
if key == "skills" and str(value).lower() not in SKILL_SOURCES:
|
|
126
|
+
raise ConfigError(f"skills must be one of {', '.join(SKILL_SOURCES)}")
|
|
127
|
+
if key == "skills":
|
|
128
|
+
return str(value).lower()
|
|
123
129
|
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
124
130
|
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
125
131
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
lcode/extensions.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Custom slash commands and skills, and the approval of those a repository brings.
|
|
2
|
+
|
|
3
|
+
Commands are prompt templates: `.lcode/commands/<name>.md` in a repository or
|
|
4
|
+
`~/.config/lcode/commands/<name>.md`, run as `/name args`. `$ARGUMENTS` in the template becomes
|
|
5
|
+
everything after the name, `$1`…`$9` single words. An optional frontmatter gives a description,
|
|
6
|
+
an argument hint for /help and the tools the model may use for that request.
|
|
7
|
+
|
|
8
|
+
Skills follow the Agent Skills format (https://agentskills.io): a folder with a SKILL.md
|
|
9
|
+
(frontmatter with name and description, then instructions) and any scripts or reference files next
|
|
10
|
+
to it. lcode looks in `.lcode/skills/`, `.agents/skills/` and `.claude/skills/` of the repository and
|
|
11
|
+
the same places in the user's home (`~/.config/lcode/skills/` for lcode's own). Only the names and
|
|
12
|
+
descriptions are in the system prompt; the instructions load when the model calls the skill tool for
|
|
13
|
+
a task that matches, or when the user runs `/name`. Bundled files are listed, not read, until needed.
|
|
14
|
+
|
|
15
|
+
A repository's commands, skills and agents can steer the model and bring scripts it may run, so
|
|
16
|
+
lcode asks before using them the first time, and again whenever they change.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from rich.markup import escape
|
|
30
|
+
|
|
31
|
+
from lcode import config
|
|
32
|
+
from lcode.checkpoints import work_tree_for
|
|
33
|
+
from lcode.frontmatter import split
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from rich.console import Console
|
|
37
|
+
|
|
38
|
+
from lcode.agent import Agent
|
|
39
|
+
|
|
40
|
+
SKILL_FOLDERS = (".lcode/skills", ".agents/skills", ".claude/skills") # in a repository, first found wins
|
|
41
|
+
PROJECT_FOLDERS = (".lcode/commands", ".lcode/agents", *SKILL_FOLDERS) # what needs approval
|
|
42
|
+
MAX_SKILLS_PROMPT = 6000 # characters of skill descriptions in the system prompt (~2K tokens)
|
|
43
|
+
MAX_DESCRIPTION = 300 # characters of one skill's description in the system prompt
|
|
44
|
+
MIN_DESCRIPTION = 40 # with many skills, descriptions are cut down to this rather than skills left out
|
|
45
|
+
MAX_RESOURCES = 50
|
|
46
|
+
MAX_FILES = 2000 # files looked at when fingerprinting a repository's extensions
|
|
47
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv"}
|
|
48
|
+
|
|
49
|
+
SKILLS_PROMPT = """
|
|
50
|
+
# Skills
|
|
51
|
+
Skills are instructions for specific kinds of tasks. When a task matches a skill's description, call the skill tool with its name first, then follow the instructions it returns. Paths in a skill are relative to its folder; use the absolute paths it gives.
|
|
52
|
+
{catalog}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExtensionError(ValueError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Command:
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
template: str
|
|
65
|
+
path: Path
|
|
66
|
+
scope: str # project | user
|
|
67
|
+
argument_hint: str = ""
|
|
68
|
+
tools: frozenset[str] | None = None
|
|
69
|
+
|
|
70
|
+
def render(self, args: str) -> str:
|
|
71
|
+
"""The request: the template with $ARGUMENTS and $1…$9 filled in."""
|
|
72
|
+
words = args.split()
|
|
73
|
+
text = self.template
|
|
74
|
+
used = "$ARGUMENTS" in text or re.search(r"\$[1-9]", text)
|
|
75
|
+
text = text.replace("$ARGUMENTS", args)
|
|
76
|
+
text = re.sub(r"\$([1-9])", lambda m: words[int(m.group(1)) - 1] if int(m.group(1)) <= len(words) else "", text)
|
|
77
|
+
if args and not used:
|
|
78
|
+
text = f"{text.rstrip()}\n\n{args}"
|
|
79
|
+
return text.strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Skill:
|
|
84
|
+
name: str
|
|
85
|
+
description: str
|
|
86
|
+
path: Path # the SKILL.md
|
|
87
|
+
scope: str
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def folder(self) -> Path:
|
|
91
|
+
return self.path.parent
|
|
92
|
+
|
|
93
|
+
def instructions(self) -> str:
|
|
94
|
+
parts = split(self.path.read_text(errors="replace"))
|
|
95
|
+
return (parts[1] if parts else self.path.read_text(errors="replace")).strip()
|
|
96
|
+
|
|
97
|
+
def resources(self) -> list[str]:
|
|
98
|
+
found = []
|
|
99
|
+
for root, dirs, files in os.walk(self.folder):
|
|
100
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
|
|
101
|
+
for name in sorted(files):
|
|
102
|
+
path = Path(root) / name
|
|
103
|
+
if path != self.path:
|
|
104
|
+
found.append(path.relative_to(self.folder).as_posix())
|
|
105
|
+
if len(found) > MAX_RESOURCES:
|
|
106
|
+
return found
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ----------------------------------------------------------------------------- loading
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def user_skill_folders(which: str = "all") -> list[Path]:
|
|
114
|
+
home = Path.home()
|
|
115
|
+
other_agents = [home / ".agents" / "skills", home / ".claude" / "skills"] if which == "all" else []
|
|
116
|
+
return [config.CONFIG_DIR / "skills", *other_agents]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_commands(cwd: Path, include_project: bool) -> tuple[dict[str, Command], list[str]]:
|
|
120
|
+
folders = [(config.CONFIG_DIR / "commands", "user")]
|
|
121
|
+
if include_project:
|
|
122
|
+
folders.append((work_tree_for(cwd) / ".lcode" / "commands", "project"))
|
|
123
|
+
commands: dict[str, Command] = {}
|
|
124
|
+
problems = []
|
|
125
|
+
for folder, scope in folders: # the repository's override the user's
|
|
126
|
+
for f in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
|
127
|
+
try:
|
|
128
|
+
commands[f.stem] = parse_command(f, scope)
|
|
129
|
+
except (ExtensionError, OSError) as e:
|
|
130
|
+
problems.append(f"{f}: {e}")
|
|
131
|
+
return commands, problems
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_command(path: Path, scope: str) -> Command:
|
|
135
|
+
from lcode.tools import TOOL_NAMES
|
|
136
|
+
|
|
137
|
+
name = path.stem
|
|
138
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", name):
|
|
139
|
+
raise ExtensionError("the file name must be lowercase letters, digits, - or _")
|
|
140
|
+
text = path.read_text(errors="replace")
|
|
141
|
+
fields, body = split(text) or ({}, text)
|
|
142
|
+
if not body.strip():
|
|
143
|
+
raise ExtensionError("the command has no prompt")
|
|
144
|
+
tools = None
|
|
145
|
+
listed = fields.get("allowed-tools") or fields.get("tools")
|
|
146
|
+
if listed:
|
|
147
|
+
tools = frozenset(t for t in re.split(r"[,\s]+", listed.strip("[]")) if t)
|
|
148
|
+
unknown = tools - TOOL_NAMES
|
|
149
|
+
if unknown:
|
|
150
|
+
raise ExtensionError(
|
|
151
|
+
f"unknown tools: {', '.join(sorted(unknown))} (lcode's tools: {', '.join(sorted(TOOL_NAMES))})"
|
|
152
|
+
)
|
|
153
|
+
first = next((line.strip() for line in body.strip().splitlines() if line.strip()), "")
|
|
154
|
+
return Command(
|
|
155
|
+
name=name,
|
|
156
|
+
description=" ".join(fields.get("description", "").split()) or first[:80],
|
|
157
|
+
template=body.strip(),
|
|
158
|
+
path=path,
|
|
159
|
+
scope=scope,
|
|
160
|
+
argument_hint=fields.get("argument-hint", ""),
|
|
161
|
+
tools=tools,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_skills(cwd: Path, include_project: bool, which: str = "all") -> tuple[dict[str, Skill], list[str]]:
|
|
166
|
+
"""Skills from lcode's folders and, with `which` = all, the folders other agents share."""
|
|
167
|
+
if which == "off":
|
|
168
|
+
return {}, []
|
|
169
|
+
places = [(folder, "user") for folder in user_skill_folders(which)]
|
|
170
|
+
if include_project:
|
|
171
|
+
root = work_tree_for(cwd)
|
|
172
|
+
places += [(root / folder, "project") for folder in (SKILL_FOLDERS if which == "all" else SKILL_FOLDERS[:1])]
|
|
173
|
+
skills: dict[str, Skill] = {}
|
|
174
|
+
problems = []
|
|
175
|
+
for folder, scope in places:
|
|
176
|
+
for skill_file in sorted(folder.glob("*/SKILL.md")) if folder.is_dir() else []:
|
|
177
|
+
try:
|
|
178
|
+
skill = parse_skill(skill_file, scope)
|
|
179
|
+
except (ExtensionError, OSError) as e:
|
|
180
|
+
problems.append(f"{skill_file}: {e}")
|
|
181
|
+
continue
|
|
182
|
+
existing = skills.get(skill.name)
|
|
183
|
+
if existing and existing.scope == scope:
|
|
184
|
+
if not same_file(existing.path, skill_file): # the same skill installed twice is fine
|
|
185
|
+
problems.append(f"{skill_file}: skipped, {existing.path} has the same name")
|
|
186
|
+
continue
|
|
187
|
+
skills[skill.name] = skill # a repository's skill replaces the user's
|
|
188
|
+
return skills, problems
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def same_file(a: Path, b: Path) -> bool:
|
|
192
|
+
try:
|
|
193
|
+
return a.resolve() == b.resolve() or a.read_bytes() == b.read_bytes()
|
|
194
|
+
except OSError:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_skill(path: Path, scope: str) -> Skill:
|
|
199
|
+
parts = split(path.read_text(errors="replace"))
|
|
200
|
+
if not parts:
|
|
201
|
+
raise ExtensionError("SKILL.md needs a frontmatter with a name and a description")
|
|
202
|
+
fields = parts[0]
|
|
203
|
+
description = " ".join(fields.get("description", "").split())
|
|
204
|
+
if not description:
|
|
205
|
+
raise ExtensionError("the frontmatter needs a description")
|
|
206
|
+
# Lenient, like other clients: a name that breaks the format rules still works, as the folder name.
|
|
207
|
+
name = fields.get("name", "").strip() or path.parent.name
|
|
208
|
+
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64:
|
|
209
|
+
name = path.parent.name
|
|
210
|
+
return Skill(name=name, description=description, path=path, scope=scope)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ----------------------------------------------------------------------------- using skills
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def skills_prompt(skills: dict[str, Skill], context: int = 262144) -> str:
|
|
217
|
+
"""The skill catalog for the system prompt: within ~5% of the context window, every skill listed
|
|
218
|
+
(descriptions are shortened first), and only then cut off."""
|
|
219
|
+
if not skills:
|
|
220
|
+
return ""
|
|
221
|
+
budget = min(MAX_SKILLS_PROMPT, context * 3 // 20)
|
|
222
|
+
room = budget // len(skills) - 6
|
|
223
|
+
lines, used = [], 0
|
|
224
|
+
for skill in skills.values():
|
|
225
|
+
limit = max(MIN_DESCRIPTION, min(MAX_DESCRIPTION, room - len(skill.name)))
|
|
226
|
+
description = skill.description
|
|
227
|
+
if len(description) > limit:
|
|
228
|
+
description = description[: limit - 1].rstrip() + "…"
|
|
229
|
+
line = f"- {skill.name}: {description}"
|
|
230
|
+
if used + len(line) > budget:
|
|
231
|
+
lines.append(f"- … and {len(skills) - len(lines)} more (the skill tool lists them all)")
|
|
232
|
+
break
|
|
233
|
+
lines.append(line)
|
|
234
|
+
used += len(line) + 1
|
|
235
|
+
return SKILLS_PROMPT.format(catalog="\n".join(lines))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def schema(skills: dict[str, Skill]) -> dict:
|
|
239
|
+
from lcode.tools import _fn
|
|
240
|
+
|
|
241
|
+
return _fn(
|
|
242
|
+
"skill",
|
|
243
|
+
"Load a skill's instructions when the task matches its description (see Skills). Returns the "
|
|
244
|
+
"instructions, the skill's folder and the files in it.",
|
|
245
|
+
{"name": {"type": "string", "enum": sorted(skills)}},
|
|
246
|
+
["name"],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def content(skill: Skill) -> str:
|
|
251
|
+
"""What the model gets when a skill is activated: instructions, folder and bundled files."""
|
|
252
|
+
files = skill.resources()
|
|
253
|
+
listing = "\n".join(f" {f}" for f in files[:MAX_RESOURCES])
|
|
254
|
+
if len(files) > MAX_RESOURCES:
|
|
255
|
+
listing += "\n … (more files; list the folder to see them)"
|
|
256
|
+
return (
|
|
257
|
+
f'<skill_content name="{skill.name}">\n{skill.instructions()}\n\n'
|
|
258
|
+
f"Skill folder: {skill.folder}\nRelative paths in this skill are relative to that folder: use "
|
|
259
|
+
"absolute paths in tool calls.\n"
|
|
260
|
+
+ (f"<skill_resources>\n{listing}\n</skill_resources>\n" if files else "")
|
|
261
|
+
+ "</skill_content>"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def activate(agent: Agent, name: str) -> str:
|
|
266
|
+
skills = agent.extensions().skills
|
|
267
|
+
if name not in skills:
|
|
268
|
+
return f"Error: no skill {name!r}. Skills: {', '.join(sorted(skills)) or 'none'}"
|
|
269
|
+
if name in agent.skills_loaded:
|
|
270
|
+
return f"The {name} skill's instructions are already loaded above; follow them."
|
|
271
|
+
try:
|
|
272
|
+
text = content(skills[name])
|
|
273
|
+
except OSError as e:
|
|
274
|
+
return f"Error: can't read the {name} skill: {e}"
|
|
275
|
+
agent.skills_loaded.add(name)
|
|
276
|
+
return text
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ----------------------------------------------------------------------------- approving a repository's extensions
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def approvals_path() -> Path:
|
|
283
|
+
return config.STATE_DIR / "extensions-approved.json"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def project_files(cwd: Path) -> tuple[Path, list[Path]]:
|
|
287
|
+
"""The repository's root and the files of its commands, skills and agents."""
|
|
288
|
+
root = work_tree_for(cwd)
|
|
289
|
+
files: list[Path] = []
|
|
290
|
+
for folder in PROJECT_FOLDERS:
|
|
291
|
+
base = root / folder
|
|
292
|
+
if not base.is_dir():
|
|
293
|
+
continue
|
|
294
|
+
for current, dirs, names in os.walk(base):
|
|
295
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
296
|
+
files += [Path(current) / n for n in sorted(names)]
|
|
297
|
+
if len(files) >= MAX_FILES:
|
|
298
|
+
return root, files[:MAX_FILES]
|
|
299
|
+
return root, files
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fingerprint(root: Path, files: list[Path]) -> str:
|
|
303
|
+
digest = hashlib.sha256()
|
|
304
|
+
for f in files:
|
|
305
|
+
digest.update(f.relative_to(root).as_posix().encode() + b"\0")
|
|
306
|
+
try:
|
|
307
|
+
digest.update(hashlib.sha256(f.read_bytes()).digest())
|
|
308
|
+
except OSError:
|
|
309
|
+
digest.update(b"?")
|
|
310
|
+
return digest.hexdigest()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _approved() -> dict:
|
|
314
|
+
try:
|
|
315
|
+
data = json.loads(approvals_path().read_text())
|
|
316
|
+
except (OSError, ValueError):
|
|
317
|
+
return {}
|
|
318
|
+
return data if isinstance(data, dict) else {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def status(cwd: Path) -> str:
|
|
322
|
+
"""none (the repository brings nothing), approved, new or changed."""
|
|
323
|
+
root, files = project_files(cwd)
|
|
324
|
+
if not files:
|
|
325
|
+
return "none"
|
|
326
|
+
seen = _approved().get(str(root))
|
|
327
|
+
if seen is None:
|
|
328
|
+
return "new"
|
|
329
|
+
return "approved" if seen == fingerprint(root, files) else "changed"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def approve(cwd: Path) -> None:
|
|
333
|
+
root, files = project_files(cwd)
|
|
334
|
+
data = _approved()
|
|
335
|
+
data[str(root)] = fingerprint(root, files)
|
|
336
|
+
approvals_path().parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
approvals_path().write_text(json.dumps(data, indent=2))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def describe(cwd: Path) -> list[str]:
|
|
341
|
+
"""What the repository brings, one line per kind."""
|
|
342
|
+
from lcode.subagents import parse_agent
|
|
343
|
+
|
|
344
|
+
root = work_tree_for(cwd)
|
|
345
|
+
commands, _ = load_commands(cwd, include_project=True)
|
|
346
|
+
skills, _ = load_skills(cwd, include_project=True)
|
|
347
|
+
agents = []
|
|
348
|
+
for f in sorted((root / ".lcode" / "agents").glob("*.md")):
|
|
349
|
+
try:
|
|
350
|
+
agents.append(parse_agent(f.read_text(errors="replace"), f.stem, str(f)).name)
|
|
351
|
+
except (ValueError, OSError):
|
|
352
|
+
continue
|
|
353
|
+
lines = []
|
|
354
|
+
project_commands = [f"/{c.name}" for c in commands.values() if c.scope == "project"]
|
|
355
|
+
project_skills = [s.name for s in skills.values() if s.scope == "project"]
|
|
356
|
+
if project_commands:
|
|
357
|
+
lines.append(f"commands: {', '.join(project_commands)}")
|
|
358
|
+
if project_skills:
|
|
359
|
+
lines.append(f"skills: {', '.join(project_skills)}")
|
|
360
|
+
if agents:
|
|
361
|
+
lines.append(f"agents: {', '.join(agents)}")
|
|
362
|
+
return lines
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def trust_project(cwd: Path, console: Console, interactive: bool) -> bool:
|
|
366
|
+
"""Whether to use the repository's commands, skills and agents; asks the first time."""
|
|
367
|
+
state = status(cwd)
|
|
368
|
+
if state in ("none", "approved"):
|
|
369
|
+
return state == "approved"
|
|
370
|
+
root = work_tree_for(cwd)
|
|
371
|
+
if not interactive:
|
|
372
|
+
console.print(
|
|
373
|
+
f"[dim]Not using the commands, skills and agents in {escape(str(root))} until you approve them in a session.[/]"
|
|
374
|
+
)
|
|
375
|
+
return False
|
|
376
|
+
changed = " [yellow](changed since you approved them)[/]" if state == "changed" else ""
|
|
377
|
+
console.print(f"This repository brings its own commands, skills or agents{changed}:")
|
|
378
|
+
for line in describe(cwd) or ["(files in " + ", ".join(PROJECT_FOLDERS) + ")"]:
|
|
379
|
+
console.print(f" {escape(line)}")
|
|
380
|
+
console.print(
|
|
381
|
+
"[dim]They can steer the model and include scripts it may run (commands still ask first, unless "
|
|
382
|
+
"you allow them).[/]"
|
|
383
|
+
)
|
|
384
|
+
try:
|
|
385
|
+
answer = input(" Use them? [y/N] ").strip().lower()
|
|
386
|
+
except EOFError:
|
|
387
|
+
answer = ""
|
|
388
|
+
if answer in ("y", "yes"):
|
|
389
|
+
approve(cwd)
|
|
390
|
+
return True
|
|
391
|
+
console.print("[dim]Not using them in this session. You'll be asked again next time.[/]")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass
|
|
396
|
+
class Extensions:
|
|
397
|
+
commands: dict[str, Command]
|
|
398
|
+
skills: dict[str, Skill]
|
|
399
|
+
problems: list[str]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def load(cwd: Path, include_project: bool, skills_from: str = "all") -> Extensions:
|
|
403
|
+
commands, command_problems = load_commands(cwd, include_project)
|
|
404
|
+
skills, skill_problems = load_skills(cwd, include_project, skills_from)
|
|
405
|
+
return Extensions(commands, skills, command_problems + skill_problems)
|
lcode/frontmatter.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The `---` header of markdown files: memory notes, agents, commands and skills.
|
|
2
|
+
|
|
3
|
+
A small, lenient subset of YAML, enough for these headers without a YAML dependency:
|
|
4
|
+
`key: value` lines, quoted values, block scalars (`key: |` and `key: >`, with indented lines below),
|
|
5
|
+
and nested maps or lists (kept as raw text). Skills written for other agents sometimes have values
|
|
6
|
+
that strict YAML rejects, such as an unquoted colon in a description; those are read as plain text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
BLOCK = re.compile(r"^[|>][+-]?$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def split(text: str) -> tuple[dict[str, str], str] | None:
|
|
17
|
+
"""The header fields and the body after them, or None when the text has no header."""
|
|
18
|
+
m = re.match(r"?---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)(.*)", text, re.S)
|
|
19
|
+
if not m:
|
|
20
|
+
return None
|
|
21
|
+
return parse(m.group(1)), m.group(2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse(header: str) -> dict[str, str]:
|
|
25
|
+
fields: dict[str, str] = {}
|
|
26
|
+
lines = header.splitlines()
|
|
27
|
+
i = 0
|
|
28
|
+
while i < len(lines):
|
|
29
|
+
line = lines[i]
|
|
30
|
+
i += 1
|
|
31
|
+
if not line.strip() or line.lstrip().startswith("#") or line[:1].isspace():
|
|
32
|
+
continue
|
|
33
|
+
key, sep, value = line.partition(":")
|
|
34
|
+
if not sep:
|
|
35
|
+
continue
|
|
36
|
+
key, value = key.strip(), value.strip()
|
|
37
|
+
nested = []
|
|
38
|
+
while i < len(lines) and (not lines[i].strip() or lines[i][:1].isspace()):
|
|
39
|
+
nested.append(lines[i])
|
|
40
|
+
i += 1
|
|
41
|
+
while nested and not nested[-1].strip():
|
|
42
|
+
nested.pop()
|
|
43
|
+
if BLOCK.match(value):
|
|
44
|
+
indent = min((len(n) - len(n.lstrip()) for n in nested if n.strip()), default=0)
|
|
45
|
+
body = [n[indent:] for n in nested]
|
|
46
|
+
if value.startswith(">"):
|
|
47
|
+
value = re.sub(r"(?<!\n)\n(?!\n)", " ", "\n".join(body)).strip()
|
|
48
|
+
else:
|
|
49
|
+
value = "\n".join(body).rstrip()
|
|
50
|
+
elif not value and nested:
|
|
51
|
+
value = "\n".join(nested) # a nested map or list, kept as text
|
|
52
|
+
elif nested:
|
|
53
|
+
value = " ".join([value, *(n.strip() for n in nested)]).strip() # a plain value over several lines
|
|
54
|
+
fields[key] = unquote(value)
|
|
55
|
+
return fields
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unquote(value: str) -> str:
|
|
59
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
60
|
+
inner = value[1:-1]
|
|
61
|
+
return inner.replace("''", "'") if value[0] == "'" else inner.replace('\\"', '"').replace("\\n", "\n")
|
|
62
|
+
return value
|
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
|
@@ -39,6 +39,7 @@ from rich.panel import Panel
|
|
|
39
39
|
from rich.text import Text
|
|
40
40
|
|
|
41
41
|
from lcode import config
|
|
42
|
+
from lcode.frontmatter import split as frontmatter
|
|
42
43
|
|
|
43
44
|
if TYPE_CHECKING:
|
|
44
45
|
from lcode.agent import Agent
|
|
@@ -186,19 +187,6 @@ def project_dir(cwd: Path) -> Path:
|
|
|
186
187
|
return memory_dir() / "projects" / f"{slugify(name, 40) or 'folder'}-{key}"
|
|
187
188
|
|
|
188
189
|
|
|
189
|
-
def frontmatter(text: str) -> tuple[dict[str, str], str] | None:
|
|
190
|
-
"""The `key: value` header between --- lines, and the body after it (None without a header)."""
|
|
191
|
-
m = re.match(r"---\r?\n(.*?)\r?\n---\r?\n?(.*)", text, re.S)
|
|
192
|
-
if not m:
|
|
193
|
-
return None
|
|
194
|
-
fields = {}
|
|
195
|
-
for line in m.group(1).splitlines():
|
|
196
|
-
key, sep, value = line.partition(":")
|
|
197
|
-
if sep:
|
|
198
|
-
fields[key.strip()] = value.strip()
|
|
199
|
-
return fields, m.group(2)
|
|
200
|
-
|
|
201
|
-
|
|
202
190
|
def parse(text: str, scope: str, path: Path | None = None) -> Note | None:
|
|
203
191
|
split = frontmatter(text)
|
|
204
192
|
if not split:
|
|
@@ -507,7 +495,7 @@ def reflect(agent: Agent) -> None:
|
|
|
507
495
|
where = "every repository" if note.scope == "user" else "this repository"
|
|
508
496
|
update = memory.find(note.name, note.scope)
|
|
509
497
|
rows.append(
|
|
510
|
-
f"[cyan]{i}[/] [{note.type}] {escape(note.description)} [dim]({where}"
|
|
498
|
+
f"[cyan]{i}[/] \\[{note.type}] {escape(note.description)} [dim]({where}"
|
|
511
499
|
f"{', updates ' + escape(update.name) if update else ''})[/]"
|
|
512
500
|
+ (f"\n [dim]{escape(note.details)}[/]" if note.details else "")
|
|
513
501
|
)
|
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", "skill",
|
|
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,13 +18,14 @@ 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
|
|
24
25
|
from rich.table import Table
|
|
25
26
|
from rich.text import Text
|
|
26
27
|
|
|
27
|
-
from lcode import __version__, catalog, limits, sessions, web
|
|
28
|
+
from lcode import __version__, catalog, extensions, limits, sessions, web
|
|
28
29
|
from lcode import memory as memory_notes
|
|
29
30
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
30
31
|
from lcode.catalog import MIN_USEFUL_CONTEXT
|
|
@@ -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",
|
|
@@ -71,7 +73,7 @@ class InputCompleter(Completer):
|
|
|
71
73
|
def get_completions(self, document, complete_event):
|
|
72
74
|
text = document.text_before_cursor
|
|
73
75
|
if text.startswith("/") and " " not in text:
|
|
74
|
-
for cmd, desc in COMMANDS.items():
|
|
76
|
+
for cmd, desc in {**COMMANDS, **custom_commands(self.agent)}.items():
|
|
75
77
|
if cmd.startswith(text):
|
|
76
78
|
yield Completion(cmd, start_position=-len(text), display_meta=desc)
|
|
77
79
|
return
|
|
@@ -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) · "
|
|
@@ -380,6 +382,48 @@ def agents_command(agent: Agent, arg: str) -> None:
|
|
|
380
382
|
)
|
|
381
383
|
|
|
382
384
|
|
|
385
|
+
def custom_commands(agent: Agent) -> dict[str, str]:
|
|
386
|
+
"""The user's and the repository's commands and skills, as /name -> description (built-ins win)."""
|
|
387
|
+
ext = agent.extensions()
|
|
388
|
+
found = {f"/{name}": skill.description for name, skill in ext.skills.items()}
|
|
389
|
+
found.update({f"/{name}": command.description for name, command in ext.commands.items()})
|
|
390
|
+
return {name: description for name, description in found.items() if name not in COMMANDS}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def help_extensions(agent: Agent) -> None:
|
|
394
|
+
c, ext = agent.console, agent.extensions()
|
|
395
|
+
if ext.commands:
|
|
396
|
+
table = Table(title="Your commands", title_justify="left", header_style="bold")
|
|
397
|
+
for column in ("Command", "Does", "From"):
|
|
398
|
+
table.add_column(column)
|
|
399
|
+
for command in ext.commands.values():
|
|
400
|
+
hidden = " [yellow](hidden by the built-in command)[/]" if f"/{command.name}" in COMMANDS else ""
|
|
401
|
+
usage = f"/{command.name}" + (f" {command.argument_hint}" if command.argument_hint else "")
|
|
402
|
+
table.add_row(escape(usage), escape(command.description) + hidden, escape(agent.tools.rel(command.path)))
|
|
403
|
+
c.print(table)
|
|
404
|
+
if ext.skills:
|
|
405
|
+
table = Table(
|
|
406
|
+
title="Skills (the model loads them when a task matches; /name runs one)",
|
|
407
|
+
title_justify="left",
|
|
408
|
+
header_style="bold",
|
|
409
|
+
)
|
|
410
|
+
for column in ("Skill", "Does", "From"):
|
|
411
|
+
table.add_column(column)
|
|
412
|
+
for skill in ext.skills.values():
|
|
413
|
+
description = skill.description if len(skill.description) <= 140 else skill.description[:139] + "…"
|
|
414
|
+
if f"/{skill.name}" in COMMANDS or skill.name in ext.commands:
|
|
415
|
+
description += f" [yellow](/{skill.name} runs the command; the model can still load the skill)[/]"
|
|
416
|
+
table.add_row(f"/{escape(skill.name)}", escape(description), escape(agent.tools.rel(skill.folder)))
|
|
417
|
+
c.print(table)
|
|
418
|
+
for problem in ext.problems:
|
|
419
|
+
c.print(f"[yellow]Skipped {escape(problem)}[/]")
|
|
420
|
+
if not agent.settings.trust_project and extensions.status(agent.cwd) in ("new", "changed"):
|
|
421
|
+
c.print(
|
|
422
|
+
"[dim]This repository's own commands, skills and agents aren't in use: you didn't approve them "
|
|
423
|
+
"(restart lcode here to be asked again).[/]"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
383
427
|
def memory_status(agent: Agent) -> str:
|
|
384
428
|
project, user = agent.memory().counts()
|
|
385
429
|
if not (project or user):
|
|
@@ -714,6 +758,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
714
758
|
"@path = attach a file · Shift+Tab = cycle permission mode"
|
|
715
759
|
)
|
|
716
760
|
c.print(Panel(f"{rows}\n\n [dim]{keys}[/]", title="lcode commands", border_style="cyan"))
|
|
761
|
+
help_extensions(agent)
|
|
717
762
|
elif cmd == "/clear":
|
|
718
763
|
memory_notes.reflect(agent)
|
|
719
764
|
agent.new_session()
|
|
@@ -772,6 +817,20 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
772
817
|
elif cmd == "/verbose":
|
|
773
818
|
s.show_thinking = not s.show_thinking
|
|
774
819
|
c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
|
|
820
|
+
elif cmd == "/plan":
|
|
821
|
+
if not arg:
|
|
822
|
+
if agent.plan:
|
|
823
|
+
c.print(Panel(Markdown(agent.plan), title="Approved plan", title_align="left", border_style="cyan"))
|
|
824
|
+
else:
|
|
825
|
+
c.print(
|
|
826
|
+
"No approved plan in this session. [bold]/plan <request>[/] works out a plan first, without "
|
|
827
|
+
"changing anything (or Shift+Tab to plan mode)."
|
|
828
|
+
)
|
|
829
|
+
return True
|
|
830
|
+
agent.perms.mode = "plan"
|
|
831
|
+
c.print("[cyan]Plan mode:[/] the model can look around but not change anything until you approve its plan.")
|
|
832
|
+
c.print(Rule(style="dim"))
|
|
833
|
+
run_safely(agent, arg)
|
|
775
834
|
elif cmd == "/mode":
|
|
776
835
|
if arg in PERMISSION_MODES:
|
|
777
836
|
agent.perms.mode = arg
|
|
@@ -791,6 +850,23 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
791
850
|
agent.tools.show_todos()
|
|
792
851
|
elif cmd == "/init":
|
|
793
852
|
run_safely(agent, INIT_PROMPT)
|
|
853
|
+
elif cmd[1:] in agent.extensions().commands:
|
|
854
|
+
command = agent.extensions().commands[cmd[1:]]
|
|
855
|
+
c.print(f"[dim]/{escape(command.name)} from {escape(agent.tools.rel(command.path))}[/]")
|
|
856
|
+
c.print(Rule(style="dim"))
|
|
857
|
+
previous = agent.allowed_tools
|
|
858
|
+
if command.tools is not None:
|
|
859
|
+
agent.allowed_tools = set(command.tools)
|
|
860
|
+
try:
|
|
861
|
+
run_safely(agent, command.render(arg))
|
|
862
|
+
finally:
|
|
863
|
+
agent.allowed_tools = previous
|
|
864
|
+
elif cmd[1:] in agent.extensions().skills:
|
|
865
|
+
skill = agent.extensions().skills[cmd[1:]]
|
|
866
|
+
agent.skills_loaded.add(skill.name)
|
|
867
|
+
c.print(f"[dim]Using the {escape(skill.name)} skill from {escape(agent.tools.rel(skill.folder))}[/]")
|
|
868
|
+
c.print(Rule(style="dim"))
|
|
869
|
+
run_safely(agent, f"{arg or f'Use the {skill.name} skill.'}\n\n{extensions.content(skill)}")
|
|
794
870
|
else:
|
|
795
871
|
c.print(f"[red]Unknown command {cmd}.[/] Type /help.")
|
|
796
872
|
return True
|
lcode/subagents.py
CHANGED
|
@@ -45,7 +45,7 @@ from rich.text import Text
|
|
|
45
45
|
|
|
46
46
|
from lcode import config
|
|
47
47
|
from lcode.checkpoints import _clean_env, work_tree_for
|
|
48
|
-
from lcode.
|
|
48
|
+
from lcode.frontmatter import split as frontmatter
|
|
49
49
|
from lcode.ollama import OllamaError
|
|
50
50
|
|
|
51
51
|
if TYPE_CHECKING:
|
|
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
|
|
|
53
53
|
from lcode.permissions import Permissions
|
|
54
54
|
|
|
55
55
|
READ_ONLY_TOOLS = frozenset(
|
|
56
|
-
{"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write"}
|
|
56
|
+
{"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write", "skill"}
|
|
57
57
|
)
|
|
58
58
|
EDIT_TOOLS = frozenset({"write_file", "edit_file"})
|
|
59
59
|
NOT_FOR_SUBAGENTS = frozenset({"agent", "memory"}) # no agents inside agents; only the main session remembers
|
|
@@ -123,9 +123,9 @@ BUILT_IN = {
|
|
|
123
123
|
# ----------------------------------------------------------------------------- agent types
|
|
124
124
|
|
|
125
125
|
|
|
126
|
-
def agent_dirs(cwd: Path) -> list[Path]:
|
|
127
|
-
"""Where custom agents live, lowest priority first: the user's, then the repository's."""
|
|
128
|
-
return [config.CONFIG_DIR / "agents", work_tree_for(cwd) / ".lcode" / "agents"]
|
|
126
|
+
def agent_dirs(cwd: Path, include_project: bool = True) -> list[Path]:
|
|
127
|
+
"""Where custom agents live, lowest priority first: the user's, then the repository's (once approved)."""
|
|
128
|
+
return [config.CONFIG_DIR / "agents", *([work_tree_for(cwd) / ".lcode" / "agents"] if include_project else [])]
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
def parse_agent(text: str, name: str, source: str) -> AgentType:
|
|
@@ -173,11 +173,11 @@ def parse_agent(text: str, name: str, source: str) -> AgentType:
|
|
|
173
173
|
)
|
|
174
174
|
|
|
175
175
|
|
|
176
|
-
def load_types(cwd: Path) -> tuple[dict[str, AgentType], list[str]]:
|
|
176
|
+
def load_types(cwd: Path, include_project: bool = True) -> tuple[dict[str, AgentType], list[str]]:
|
|
177
177
|
"""The agent types for `cwd` (custom ones override built-in ones), and problems with custom ones."""
|
|
178
178
|
types = dict(BUILT_IN)
|
|
179
179
|
problems = []
|
|
180
|
-
for folder in agent_dirs(cwd):
|
|
180
|
+
for folder in agent_dirs(cwd, include_project):
|
|
181
181
|
if not folder.is_dir():
|
|
182
182
|
continue
|
|
183
183
|
for f in sorted(folder.glob("*.md")):
|
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,9 +199,23 @@ 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"]
|
|
203
|
-
|
|
216
|
+
s["function"]["name"]
|
|
217
|
+
for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA, PRESENT_PLAN_SCHEMA]
|
|
218
|
+
} | {"agent", "skill"} # those two have schemas that depend on the session
|
|
204
219
|
|
|
205
220
|
|
|
206
221
|
# ----------------------------------------------------------------------------- helpers
|
|
@@ -319,10 +334,15 @@ class Toolbox:
|
|
|
319
334
|
p = Path(os.path.expanduser(path or "."))
|
|
320
335
|
return (p if p.is_absolute() else self.agent.cwd / p).resolve()
|
|
321
336
|
|
|
322
|
-
def path(self, path: str | None) -> Path:
|
|
323
|
-
"""Resolve a path from the model; with the sandbox on, it must be inside the project
|
|
337
|
+
def path(self, path: str | None, read: bool = False) -> Path:
|
|
338
|
+
"""Resolve a path from the model; with the sandbox on, it must be inside the project (or, for
|
|
339
|
+
reading, inside a skill's folder)."""
|
|
324
340
|
p = self.resolve(path)
|
|
325
341
|
root = self.agent.sandbox_root()
|
|
342
|
+
if read and root is not None:
|
|
343
|
+
skills = self.agent.extensions().skills.values()
|
|
344
|
+
if any(p == s.folder or s.folder in p.parents for s in skills):
|
|
345
|
+
return p
|
|
326
346
|
if root is not None and p != root and root not in p.parents:
|
|
327
347
|
raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
|
|
328
348
|
return p
|
|
@@ -337,6 +357,10 @@ class Toolbox:
|
|
|
337
357
|
allowed = self.agent.allowed_tools
|
|
338
358
|
if allowed is not None and name not in allowed:
|
|
339
359
|
return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
|
|
360
|
+
if self.agent.planning() and name not in PLAN_MODE_TOOLS:
|
|
361
|
+
return f"Error: {BLOCKED}"
|
|
362
|
+
if name == "present_plan" and not self.agent.planning():
|
|
363
|
+
return "Error: plan mode is off, so there's nothing to present: carry on with the work."
|
|
340
364
|
if self.agent.mcp and self.agent.mcp.owns(name):
|
|
341
365
|
return self._mcp(name, args)
|
|
342
366
|
fn = getattr(self, f"t_{name}", None)
|
|
@@ -376,8 +400,26 @@ class Toolbox:
|
|
|
376
400
|
raise ToolError("subagents are turned off")
|
|
377
401
|
from lcode import subagents
|
|
378
402
|
|
|
403
|
+
kind = self.agent.agent_types().get(type)
|
|
404
|
+
if self.agent.planning() and kind and not kind.read_only:
|
|
405
|
+
raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
|
|
379
406
|
return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
|
|
380
407
|
|
|
408
|
+
# -- plan mode
|
|
409
|
+
def t_present_plan(self, title: str, plan: str) -> str:
|
|
410
|
+
from lcode.planning import present
|
|
411
|
+
|
|
412
|
+
return present(self.agent, title, plan)
|
|
413
|
+
|
|
414
|
+
# -- skills
|
|
415
|
+
def t_skill(self, name: str) -> str:
|
|
416
|
+
from lcode.extensions import activate
|
|
417
|
+
|
|
418
|
+
result = activate(self.agent, name)
|
|
419
|
+
if not result.startswith("Error:"):
|
|
420
|
+
self.console.print(Text(f" ⎿ loaded the {name} skill", style="dim"))
|
|
421
|
+
return result
|
|
422
|
+
|
|
381
423
|
# -- memory
|
|
382
424
|
def t_memory(
|
|
383
425
|
self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
|
|
@@ -431,7 +473,7 @@ class Toolbox:
|
|
|
431
473
|
|
|
432
474
|
# -- read-only
|
|
433
475
|
def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
|
434
|
-
p = self.path(path)
|
|
476
|
+
p = self.path(path, read=True)
|
|
435
477
|
if not p.exists():
|
|
436
478
|
raise ToolError(f"{path} does not exist")
|
|
437
479
|
if p.is_dir():
|
|
@@ -453,7 +495,7 @@ class Toolbox:
|
|
|
453
495
|
return truncate(out, 120_000)
|
|
454
496
|
|
|
455
497
|
def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
|
|
456
|
-
p = self.path(path)
|
|
498
|
+
p = self.path(path, read=True)
|
|
457
499
|
if not p.is_dir():
|
|
458
500
|
raise ToolError(f"{path} is not a directory")
|
|
459
501
|
return tree(p, depth=max(1, min(int(depth or 2), 6)))
|
|
@@ -605,6 +647,8 @@ class Toolbox:
|
|
|
605
647
|
# -- shell
|
|
606
648
|
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
607
649
|
sandbox = self.agent.sandbox
|
|
650
|
+
if self.agent.planning() and not is_read_only(command):
|
|
651
|
+
raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
|
|
608
652
|
if self.agent.read_only and not is_read_only(command):
|
|
609
653
|
raise ToolError(
|
|
610
654
|
"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.10.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,10 @@ 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
|
+
- **Commands and skills.** Your own `/commands` as prompt templates, and skills in the
|
|
84
|
+
[Agent Skills](https://agentskills.io) format that the model loads only when a task needs them.
|
|
85
|
+
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
86
|
+
you approve, edit or send it back before anything changes.
|
|
83
87
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
84
88
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
85
89
|
- **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
|
|
@@ -195,7 +199,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
195
199
|
|
|
196
200
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
197
201
|
|
|
198
|
-
- [Plan mode](https://github.com/nasser1941/lcode/issues/41) and [custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
|
|
199
202
|
- [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
|
|
200
203
|
- 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
204
|
- 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,26 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=Vt4u1brdsy5UMBysZVsHXtLIwN6S7GISlXj5GAGQI1w,118
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=5vSYZYFg-5doSF1yx3YRWI8hKSMCxPnSNzJiIwicY_8,40626
|
|
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=2xSELCKgYyqvJUAwG6P6qimZq6wmZGxwYAxCYfeZ7rk,30509
|
|
8
|
+
lcode/config.py,sha256=HwOAz-9LFkH9hOlQwPPG26oIGtOfd0b2Wq2I09WxIAE,8297
|
|
9
|
+
lcode/extensions.py,sha256=1MkCmfvQvcJt1XaqIEOYUruCTsrfQVI5TXOTWBkkJtI,15882
|
|
10
|
+
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
9
11
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
10
12
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
11
|
-
lcode/memory.py,sha256=
|
|
13
|
+
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
12
14
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
13
15
|
lcode/ollama.py,sha256=0f2rkrm3_5Xcnuyvaxcfx5SeYVyRvHjiROXryHKmRUw,7085
|
|
14
16
|
lcode/permissions.py,sha256=m8YNtCladVAmXB20ca8AwyfEOzHKS557B0SOvx1oHeM,4528
|
|
17
|
+
lcode/planning.py,sha256=Z30K2tbqoy2XimKwth1lYYz7OVaQUXGdDvBgops7pGE,6076
|
|
15
18
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
16
|
-
lcode/repl.py,sha256=
|
|
19
|
+
lcode/repl.py,sha256=FnyYpRuvpqmIVWQbMSvagrFBvFcLjIAedFeF32u5Vp4,43612
|
|
17
20
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
18
21
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
19
|
-
lcode/subagents.py,sha256=
|
|
20
|
-
lcode/tools.py,sha256=
|
|
22
|
+
lcode/subagents.py,sha256=ILGFwdFWfwKqExl2D5h_AArqlOris2N0aqh3Ccj895Q,28295
|
|
23
|
+
lcode/tools.py,sha256=Af2pbx8KIR4uLaWk2opbzMY8ZFQ22mGcu8FnRbX9DsM,35486
|
|
21
24
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
22
25
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
23
26
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -25,14 +28,14 @@ lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
|
|
|
25
28
|
lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
|
|
26
29
|
lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
|
|
27
30
|
lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
|
|
28
|
-
lcode/mcp/commands.py,sha256=
|
|
31
|
+
lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
|
|
29
32
|
lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
|
|
30
33
|
lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
|
|
31
34
|
lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
|
|
32
35
|
lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
|
|
33
36
|
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.
|
|
37
|
+
lcode_cli-0.10.0.dist-info/METADATA,sha256=qlFYNWlZjDePi7kqZ2JnRUVasJODV0Emqu6SDFhKvhI,11487
|
|
38
|
+
lcode_cli-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
39
|
+
lcode_cli-0.10.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
|
|
40
|
+
lcode_cli-0.10.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
41
|
+
lcode_cli-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|