lcode-cli 0.9.0__py3-none-any.whl → 0.11.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 +25 -3
- lcode/cli.py +19 -0
- lcode/config.py +6 -0
- lcode/extensions.py +405 -0
- lcode/frontmatter.py +62 -0
- lcode/mcp/catalog.py +4 -0
- lcode/mcp/catalog.toml +57 -1
- lcode/mcp/server.py +152 -0
- lcode/memory.py +1 -13
- lcode/planning.py +1 -1
- lcode/repl.py +62 -2
- lcode/servers/__init__.py +1 -0
- lcode/servers/encord.py +285 -0
- lcode/servers/valohai.py +329 -0
- lcode/subagents.py +7 -7
- lcode/tools.py +19 -5
- {lcode_cli-0.9.0.dist-info → lcode_cli-0.11.0.dist-info}/METADATA +10 -5
- {lcode_cli-0.9.0.dist-info → lcode_cli-0.11.0.dist-info}/RECORD +22 -16
- lcode_cli-0.11.0.dist-info/entry_points.txt +4 -0
- lcode_cli-0.9.0.dist-info/entry_points.txt +0 -2
- {lcode_cli-0.9.0.dist-info → lcode_cli-0.11.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.9.0.dist-info → lcode_cli-0.11.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, planning, 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
|
|
@@ -78,7 +78,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
78
78
|
|
|
79
79
|
# Top-level layout of the working directory
|
|
80
80
|
{tree}
|
|
81
|
-
{web}{agents}{project}{notes}"""
|
|
81
|
+
{web}{agents}{skills}{project}{notes}"""
|
|
82
82
|
|
|
83
83
|
WEB_PROMPT = """
|
|
84
84
|
# Web access
|
|
@@ -171,6 +171,8 @@ class Settings:
|
|
|
171
171
|
vision_model: str = "auto" # auto, off or an Ollama model that can see images
|
|
172
172
|
memory: str = "off" # off, ask or auto: notes that carry over to later sessions (lcode.memory)
|
|
173
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
|
|
174
176
|
max_parallel_agents: int = 1
|
|
175
177
|
|
|
176
178
|
|
|
@@ -200,6 +202,8 @@ class Agent:
|
|
|
200
202
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
201
203
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
202
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
|
|
203
207
|
# Set on subagents (see lcode.subagents):
|
|
204
208
|
self.allowed_tools: set[str] | None = None # None: every tool
|
|
205
209
|
self.read_only = False # only read-only shell commands
|
|
@@ -236,8 +240,15 @@ class Agent:
|
|
|
236
240
|
notes=self.memory().prompt(self.settings.context) if self.settings.memory != "off" else "",
|
|
237
241
|
web=self.web_prompt(),
|
|
238
242
|
agents=self.agents_prompt(),
|
|
243
|
+
skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
|
|
239
244
|
)
|
|
240
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
|
+
|
|
241
252
|
def agents_prompt(self) -> str:
|
|
242
253
|
if not self.settings.subagents:
|
|
243
254
|
return ""
|
|
@@ -252,7 +263,7 @@ class Agent:
|
|
|
252
263
|
def agent_types(self) -> dict[str, subagents.AgentType]:
|
|
253
264
|
"""Built-in and custom agent types for the current folder."""
|
|
254
265
|
if self._agent_types is None or self._agent_types[0] != self.cwd:
|
|
255
|
-
types, problems = subagents.load_types(self.cwd)
|
|
266
|
+
types, problems = subagents.load_types(self.cwd, self.settings.trust_project)
|
|
256
267
|
self._agent_types = (self.cwd, types, problems)
|
|
257
268
|
return self._agent_types[1]
|
|
258
269
|
|
|
@@ -290,6 +301,8 @@ class Agent:
|
|
|
290
301
|
schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
|
|
291
302
|
if self.planning():
|
|
292
303
|
schemas.append(PRESENT_PLAN_SCHEMA)
|
|
304
|
+
if self.extensions().skills:
|
|
305
|
+
schemas.append(extensions.schema(self.extensions().skills))
|
|
293
306
|
if self.mcp:
|
|
294
307
|
schemas += self.mcp.schemas(self.settings.context)
|
|
295
308
|
if self.allowed_tools is not None:
|
|
@@ -409,6 +422,7 @@ class Agent:
|
|
|
409
422
|
|
|
410
423
|
def new_session(self) -> None:
|
|
411
424
|
self.reset()
|
|
425
|
+
self.skills_loaded.clear()
|
|
412
426
|
self.session_id = self.new_session_id()
|
|
413
427
|
self.session_name = ""
|
|
414
428
|
self.session_title = ""
|
|
@@ -606,6 +620,8 @@ class Agent:
|
|
|
606
620
|
return f"list_dir({args.get('path', '.')})"
|
|
607
621
|
if name == "memory":
|
|
608
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', '')})"
|
|
609
625
|
if name == "agent":
|
|
610
626
|
return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
|
|
611
627
|
if name == "bash":
|
|
@@ -787,6 +803,12 @@ class Agent:
|
|
|
787
803
|
]
|
|
788
804
|
if self.plan: # the approved plan stays word for word
|
|
789
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()
|
|
790
812
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
791
813
|
self.reflected = len(self.messages)
|
|
792
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:
|
|
@@ -528,6 +542,9 @@ def cmd_chat(args) -> None:
|
|
|
528
542
|
num_batch = spec.num_batch
|
|
529
543
|
else: # the tuned batch size assumes the text-only variant; the vision projector needs that VRAM
|
|
530
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)
|
|
531
548
|
settings = Settings(
|
|
532
549
|
model=model,
|
|
533
550
|
context=context,
|
|
@@ -547,6 +564,8 @@ def cmd_chat(args) -> None:
|
|
|
547
564
|
memory="off" if args.no_memory else cfg["memory"],
|
|
548
565
|
subagents=cfg["subagents"],
|
|
549
566
|
max_parallel_agents=cfg["max_parallel_agents"],
|
|
567
|
+
trust_project=trust_project,
|
|
568
|
+
skills=cfg["skills"],
|
|
550
569
|
)
|
|
551
570
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
552
571
|
agent.interactive = not args.prompt
|
lcode/config.py
CHANGED
|
@@ -27,6 +27,7 @@ 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]] = {
|
|
@@ -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/catalog.py
CHANGED
|
@@ -99,6 +99,10 @@ def build(preset: Preset, answers: dict[str, str | None]) -> dict:
|
|
|
99
99
|
server = copy.deepcopy(preset.server)
|
|
100
100
|
for spec in preset.inputs:
|
|
101
101
|
value = answers.get(spec.var)
|
|
102
|
+
if value and spec.var.endswith(("_URL", "_HOST")):
|
|
103
|
+
value = value.rstrip("/") # the URL may have a path appended, e.g. ${METABASE_URL}/api/metabase-mcp
|
|
104
|
+
if "://" not in value:
|
|
105
|
+
value = "https://" + value # people type metabase.example.com
|
|
102
106
|
if value:
|
|
103
107
|
server = _replace(server, spec.var, value)
|
|
104
108
|
elif spec.optional and not os.environ.get(spec.var):
|