localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
localcode/skills.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""LocalCode skills — markdown-based prompt templates with a local registry.
|
|
2
|
+
|
|
3
|
+
Two invocation paths exist in parallel, both reading from the same `*.md`
|
|
4
|
+
files on disk:
|
|
5
|
+
|
|
6
|
+
1. **User-invoked** (`@skillname` / `#skillname` in chat) — the original
|
|
7
|
+
path. `resolve_referenced_skills()` scans the user's text and
|
|
8
|
+
expands matching names into the conversation. Good for "please do
|
|
9
|
+
a review" when the user explicitly asks.
|
|
10
|
+
|
|
11
|
+
2. **Model-invoked** (skill tool) — added for Qwen3.6/Gemma 4 agents.
|
|
12
|
+
The model sees a one-line listing of each skill in its system prompt
|
|
13
|
+
and calls `skill(name=...)` when it judges a pattern-match. The tool
|
|
14
|
+
returns the full skill body; the next model turn acts on it.
|
|
15
|
+
Research basis: Voyager (arXiv 2305.16291), terminal coding tools bundled
|
|
16
|
+
skills, OpenAI agent `SkillTool` architecture. See ARCHITECTURE.md
|
|
17
|
+
§1.11 for why this specifically helps small quantized models.
|
|
18
|
+
|
|
19
|
+
Skills declare optional YAML-lite frontmatter:
|
|
20
|
+
|
|
21
|
+
```markdown
|
|
22
|
+
---
|
|
23
|
+
name: run-tests
|
|
24
|
+
description: Detect the test framework and run tests, parsing results.
|
|
25
|
+
when_to_use: User asks to run tests / verify a change / after you've
|
|
26
|
+
edited code that has tests.
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<body — instructions the model follows on invocation>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The parser accepts skills WITHOUT frontmatter (treats the whole file
|
|
33
|
+
as the body and derives a minimal description). This keeps the
|
|
34
|
+
existing `@skill` path working while letting new skills opt into
|
|
35
|
+
richer metadata.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
|
|
47
|
+
from .config import ensure_home_dirs
|
|
48
|
+
|
|
49
|
+
SKILL_PATTERN = re.compile(r"(?:@|#)([a-zA-Z0-9_-]+)")
|
|
50
|
+
REGISTRY_FILE = ensure_home_dirs() / "skills" / "registry.json"
|
|
51
|
+
|
|
52
|
+
# Cap on the one-liner the model sees in the prompt listing. Mirrors the
|
|
53
|
+
# pattern agent uses to prevent one skill from dominating the budget.
|
|
54
|
+
DESCRIPTION_CAP = 200
|
|
55
|
+
|
|
56
|
+
# Frontmatter validation caps (borrowed from minimal-agent/coding-agent spec).
|
|
57
|
+
# Names kept short so the model's system-prompt listing stays compact; a
|
|
58
|
+
# long description is truncated rather than rejected so legacy skills
|
|
59
|
+
# keep working.
|
|
60
|
+
MAX_NAME_LENGTH = 64
|
|
61
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Skill directories ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def skill_dirs(repo_root: Path) -> list[Path]:
|
|
67
|
+
return [
|
|
68
|
+
ensure_home_dirs() / "skills",
|
|
69
|
+
repo_root / ".localcode" / "skills",
|
|
70
|
+
repo_root / ".agents" / "skills",
|
|
71
|
+
repo_root / ".claude" / "skills",
|
|
72
|
+
repo_root / ".opencode" / "skills",
|
|
73
|
+
Path.home() / ".agents" / "skills",
|
|
74
|
+
Path.home() / ".claude" / "skills",
|
|
75
|
+
Path.home() / ".config" / "opencode" / "skills",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _skill_files(directory: Path) -> list[Path]:
|
|
80
|
+
"""Discover legacy flat markdown plus Agent Skills SKILL.md packages."""
|
|
81
|
+
if not directory.is_dir():
|
|
82
|
+
return []
|
|
83
|
+
files = list(directory.glob("*.md"))
|
|
84
|
+
files.extend(directory.rglob("SKILL.md"))
|
|
85
|
+
return sorted(dict.fromkeys(files))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _global_skill_dir() -> Path:
|
|
89
|
+
d = ensure_home_dirs() / "skills"
|
|
90
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
return d
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Core API (unchanged interface) ───────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def list_skills(repo_root: Path) -> list[str]:
|
|
97
|
+
names: set[str] = set()
|
|
98
|
+
for directory in skill_dirs(repo_root):
|
|
99
|
+
if not directory.exists():
|
|
100
|
+
continue
|
|
101
|
+
for path in _skill_files(directory):
|
|
102
|
+
names.add(path.parent.name if path.name == "SKILL.md" else path.stem)
|
|
103
|
+
return sorted(names)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_skill(repo_root: Path, name: str) -> str | None:
|
|
107
|
+
for directory in skill_dirs(repo_root):
|
|
108
|
+
for path in (directory / f"{name}.md", directory / name / "SKILL.md"):
|
|
109
|
+
if path.exists():
|
|
110
|
+
return path.read_text(errors="replace")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_referenced_skills(repo_root: Path, text: str) -> list[tuple[str, str]]:
|
|
115
|
+
resolved: list[tuple[str, str]] = []
|
|
116
|
+
seen: set[str] = set()
|
|
117
|
+
for name in SKILL_PATTERN.findall(text):
|
|
118
|
+
if name in seen:
|
|
119
|
+
continue
|
|
120
|
+
content = load_skill(repo_root, name)
|
|
121
|
+
if content:
|
|
122
|
+
resolved.append((name, content))
|
|
123
|
+
seen.add(name)
|
|
124
|
+
return resolved
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Registry ─────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def _load_registry() -> dict:
|
|
130
|
+
if not REGISTRY_FILE.exists():
|
|
131
|
+
return {"skills": {}, "sources": []}
|
|
132
|
+
try:
|
|
133
|
+
return json.loads(REGISTRY_FILE.read_text())
|
|
134
|
+
except Exception:
|
|
135
|
+
return {"skills": {}, "sources": []}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _save_registry(data: dict) -> None:
|
|
139
|
+
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
REGISTRY_FILE.write_text(json.dumps(data, indent=2))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Install / Remove ────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def install_skill(source: str) -> tuple[bool, str]:
|
|
146
|
+
"""Install a skill from a local path or URL.
|
|
147
|
+
|
|
148
|
+
Supports:
|
|
149
|
+
- Local .md file path
|
|
150
|
+
- URL to a raw .md file
|
|
151
|
+
- Directory containing .md files
|
|
152
|
+
"""
|
|
153
|
+
source_path = Path(source).expanduser()
|
|
154
|
+
|
|
155
|
+
# Local file
|
|
156
|
+
if source_path.is_file() and source_path.suffix == ".md":
|
|
157
|
+
return _install_local_file(source_path)
|
|
158
|
+
|
|
159
|
+
# Local directory
|
|
160
|
+
if source_path.is_dir():
|
|
161
|
+
installed = []
|
|
162
|
+
for md_file in source_path.glob("*.md"):
|
|
163
|
+
ok, msg = _install_local_file(md_file)
|
|
164
|
+
if ok:
|
|
165
|
+
installed.append(md_file.stem)
|
|
166
|
+
if installed:
|
|
167
|
+
return True, f"Installed {len(installed)} skills: {', '.join(installed)}"
|
|
168
|
+
return False, f"No .md skill files found in {source}"
|
|
169
|
+
|
|
170
|
+
# URL
|
|
171
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
172
|
+
return _install_from_url(source)
|
|
173
|
+
|
|
174
|
+
return False, f"Cannot install from '{source}'. Provide a .md file, directory, or URL."
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _install_local_file(path: Path) -> tuple[bool, str]:
|
|
178
|
+
dest = _global_skill_dir() / path.name
|
|
179
|
+
shutil.copy2(path, dest)
|
|
180
|
+
name = path.stem
|
|
181
|
+
reg = _load_registry()
|
|
182
|
+
reg["skills"][name] = {
|
|
183
|
+
"source": str(path),
|
|
184
|
+
"path": str(dest),
|
|
185
|
+
}
|
|
186
|
+
_save_registry(reg)
|
|
187
|
+
return True, f"Installed skill '{name}' -> {dest}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _install_from_url(url: str) -> tuple[bool, str]:
|
|
191
|
+
try:
|
|
192
|
+
response = httpx.get(url, timeout=20.0, follow_redirects=True)
|
|
193
|
+
response.raise_for_status()
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
return False, f"Failed to fetch {url}: {exc}"
|
|
196
|
+
|
|
197
|
+
# Derive name from URL
|
|
198
|
+
url_path = url.rstrip("/").split("/")[-1]
|
|
199
|
+
if not url_path.endswith(".md"):
|
|
200
|
+
url_path = url_path + ".md"
|
|
201
|
+
name = url_path.removesuffix(".md")
|
|
202
|
+
|
|
203
|
+
dest = _global_skill_dir() / url_path
|
|
204
|
+
dest.write_text(response.text)
|
|
205
|
+
|
|
206
|
+
reg = _load_registry()
|
|
207
|
+
reg["skills"][name] = {
|
|
208
|
+
"source": url,
|
|
209
|
+
"path": str(dest),
|
|
210
|
+
}
|
|
211
|
+
_save_registry(reg)
|
|
212
|
+
return True, f"Installed skill '{name}' from {url} -> {dest}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def remove_skill(name: str) -> tuple[bool, str]:
|
|
216
|
+
"""Remove an installed skill."""
|
|
217
|
+
# `name` comes from the command layer as a raw string; without this,
|
|
218
|
+
# remove_skill("../../Documents/notes") would unlink ~/Documents/notes.md.
|
|
219
|
+
import re as _re
|
|
220
|
+
if not _re.fullmatch(r"[A-Za-z0-9_-]+", name or ""):
|
|
221
|
+
return False, f"Invalid skill name: {name!r}"
|
|
222
|
+
dest = _global_skill_dir() / f"{name}.md"
|
|
223
|
+
if dest.exists():
|
|
224
|
+
dest.unlink()
|
|
225
|
+
reg = _load_registry()
|
|
226
|
+
if name in reg.get("skills", {}):
|
|
227
|
+
del reg["skills"][name]
|
|
228
|
+
_save_registry(reg)
|
|
229
|
+
return True, f"Removed skill '{name}'"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def search_skills(query: str, repo_root: Path) -> list[dict[str, str]]:
|
|
233
|
+
"""Search installed skills by name and content."""
|
|
234
|
+
query_lower = query.lower()
|
|
235
|
+
results: list[dict[str, str]] = []
|
|
236
|
+
for directory in skill_dirs(repo_root):
|
|
237
|
+
if not directory.exists():
|
|
238
|
+
continue
|
|
239
|
+
for path in directory.glob("*.md"):
|
|
240
|
+
name = path.stem
|
|
241
|
+
content = path.read_text(errors="replace")
|
|
242
|
+
if query_lower in name.lower() or query_lower in content[:500].lower():
|
|
243
|
+
preview = content[:200].replace("\n", " ").strip()
|
|
244
|
+
results.append({
|
|
245
|
+
"name": name,
|
|
246
|
+
"path": str(path),
|
|
247
|
+
"preview": preview,
|
|
248
|
+
})
|
|
249
|
+
return results
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def skill_info(name: str, repo_root: Path) -> dict[str, str] | None:
|
|
253
|
+
"""Get info about a specific skill."""
|
|
254
|
+
content = load_skill(repo_root, name)
|
|
255
|
+
if content is None:
|
|
256
|
+
return None
|
|
257
|
+
reg = _load_registry()
|
|
258
|
+
entry = reg.get("skills", {}).get(name, {})
|
|
259
|
+
return {
|
|
260
|
+
"name": name,
|
|
261
|
+
"source": entry.get("source", "local"),
|
|
262
|
+
"path": entry.get("path", ""),
|
|
263
|
+
"content_preview": content[:500],
|
|
264
|
+
"size": str(len(content)),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _bundled_skill_dir() -> Path:
|
|
270
|
+
"""Directory where the package's built-in starter skills live.
|
|
271
|
+
|
|
272
|
+
These ship as `.md` files alongside the Python code (one file per
|
|
273
|
+
skill) so users can inspect, copy, and customize them the same way
|
|
274
|
+
they would their own skills — no dict-of-strings opacity.
|
|
275
|
+
"""
|
|
276
|
+
return Path(__file__).parent / "skills"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def ensure_builtin_skills() -> int:
|
|
280
|
+
"""Copy bundled `.md` skills into `~/.localcode/skills/` if not already there.
|
|
281
|
+
|
|
282
|
+
Returns the count newly created. We copy rather than symlink so
|
|
283
|
+
user edits to `~/.localcode/skills/foo.md` stay local and survive package
|
|
284
|
+
upgrades. The package-bundled originals remain the fallback
|
|
285
|
+
registry source — see `load_registry`.
|
|
286
|
+
"""
|
|
287
|
+
bundled = _bundled_skill_dir()
|
|
288
|
+
if not bundled.is_dir():
|
|
289
|
+
return 0
|
|
290
|
+
target = _global_skill_dir()
|
|
291
|
+
created = 0
|
|
292
|
+
for path in sorted(bundled.glob("*.md")):
|
|
293
|
+
dst = target / path.name
|
|
294
|
+
if not dst.exists():
|
|
295
|
+
dst.write_text(path.read_text(encoding="utf-8"))
|
|
296
|
+
created += 1
|
|
297
|
+
return created
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── Model-invoked skill tool (Voyager / agent pattern) ──────────────────
|
|
301
|
+
#
|
|
302
|
+
# The body-per-invocation design keeps the system prompt tiny even as the
|
|
303
|
+
# skill count grows. Each skill contributes ~1 line to the prompt; the
|
|
304
|
+
# full body (which may be 500-2000 tokens of detailed instructions)
|
|
305
|
+
# only loads when the model calls the tool.
|
|
306
|
+
|
|
307
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)\Z", re.DOTALL)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _truthy(val: str) -> bool:
|
|
311
|
+
"""Parse YAML-lite boolean: true/yes/1 → True, anything else → False."""
|
|
312
|
+
return val.strip().lower() in ("true", "yes", "1", "on")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _clip(s: str, cap: int) -> str:
|
|
316
|
+
"""Truncate s to cap chars with an ellipsis; preserves leading strip."""
|
|
317
|
+
s = s.strip()
|
|
318
|
+
if len(s) <= cap:
|
|
319
|
+
return s
|
|
320
|
+
return s[: cap - 1].rstrip() + "…"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass
|
|
324
|
+
class Skill:
|
|
325
|
+
"""Parsed skill — frontmatter metadata + markdown body."""
|
|
326
|
+
name: str
|
|
327
|
+
description: str
|
|
328
|
+
when_to_use: str
|
|
329
|
+
body: str
|
|
330
|
+
source_path: Path
|
|
331
|
+
origin: str # "user" | "project" | "bundled"
|
|
332
|
+
# When True, skill body is still reachable via @-reference or direct
|
|
333
|
+
# invocation, but it's hidden from the model's skill listing so the
|
|
334
|
+
# model won't call it autonomously. Useful for skills that require
|
|
335
|
+
# human judgment (e.g. destructive-ops recipes).
|
|
336
|
+
disable_model_invocation: bool = False
|
|
337
|
+
|
|
338
|
+
def one_line(self) -> str:
|
|
339
|
+
"""Short listing entry injected into the system prompt."""
|
|
340
|
+
desc = self.description.strip().replace("\n", " ")
|
|
341
|
+
if len(desc) > DESCRIPTION_CAP:
|
|
342
|
+
desc = desc[: DESCRIPTION_CAP - 1] + "…"
|
|
343
|
+
return f"- `{self.name}` — {desc}"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class SkillRegistry:
|
|
348
|
+
"""Resolved set of skills available this session. Rebuilt per agent-loop."""
|
|
349
|
+
skills: dict[str, Skill] = field(default_factory=dict)
|
|
350
|
+
|
|
351
|
+
def get(self, name: str) -> Skill | None:
|
|
352
|
+
return self.skills.get(name)
|
|
353
|
+
|
|
354
|
+
def ordered(self) -> list[Skill]:
|
|
355
|
+
return sorted(self.skills.values(), key=lambda s: s.name)
|
|
356
|
+
|
|
357
|
+
def listing(self, max_chars: int = 6_000) -> str:
|
|
358
|
+
"""Block for the system prompt — empty string if no skills registered.
|
|
359
|
+
|
|
360
|
+
Skips any skill with `disable_model_invocation=True` — the model
|
|
361
|
+
doesn't need to see recipes it can't/shouldn't auto-invoke.
|
|
362
|
+
"""
|
|
363
|
+
visible = [s for s in self.ordered() if not s.disable_model_invocation]
|
|
364
|
+
if not visible:
|
|
365
|
+
return ""
|
|
366
|
+
lines: list[str] = []
|
|
367
|
+
used = 0
|
|
368
|
+
# Bundled coding fundamentals first, then project/user extensions.
|
|
369
|
+
visible.sort(key=lambda s: (s.origin != "bundled", s.name))
|
|
370
|
+
for skill in visible:
|
|
371
|
+
line = skill.one_line()
|
|
372
|
+
if lines and used + len(line) + 1 > max_chars:
|
|
373
|
+
break
|
|
374
|
+
lines.append(line)
|
|
375
|
+
used += len(line) + 1
|
|
376
|
+
hidden = len(visible) - len(lines)
|
|
377
|
+
if hidden:
|
|
378
|
+
lines.append(f"- … {hidden} more installed skills; use /skills to inspect them.")
|
|
379
|
+
return "\n".join(lines)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def select_dynamic_skills(
|
|
383
|
+
user_text: str,
|
|
384
|
+
registry: SkillRegistry,
|
|
385
|
+
*,
|
|
386
|
+
recent_tools: list[str] | None = None,
|
|
387
|
+
last_failed_tool: str | None = None,
|
|
388
|
+
target_chars: int = 1400,
|
|
389
|
+
) -> list[Skill]:
|
|
390
|
+
"""Select a small per-turn skill set.
|
|
391
|
+
|
|
392
|
+
Priority is intentionally narrow: explicit recovery from the last
|
|
393
|
+
failed tool first, then current-turn intent. Recent tools are
|
|
394
|
+
telemetry-only because they are stale across follow-up turns.
|
|
395
|
+
`target_chars` is a soft prompt-budget target, not a turn cap.
|
|
396
|
+
"""
|
|
397
|
+
selected: list[Skill] = []
|
|
398
|
+
used = 0
|
|
399
|
+
|
|
400
|
+
def add(name: str) -> None:
|
|
401
|
+
nonlocal used
|
|
402
|
+
skill = registry.get(name)
|
|
403
|
+
if skill is None or skill.disable_model_invocation:
|
|
404
|
+
return
|
|
405
|
+
if any(s.name == skill.name for s in selected):
|
|
406
|
+
return
|
|
407
|
+
cost = len(skill.body) + len(skill.description)
|
|
408
|
+
if selected and used + cost > target_chars:
|
|
409
|
+
return
|
|
410
|
+
selected.append(skill)
|
|
411
|
+
used += cost
|
|
412
|
+
|
|
413
|
+
if last_failed_tool:
|
|
414
|
+
add(_tool_to_skill_name(last_failed_tool))
|
|
415
|
+
# Do not auto-inject from stale recent tools. Telemetry still records
|
|
416
|
+
# recent-tool candidates, but selecting from them polluted follow-up
|
|
417
|
+
# turns: a prior bash launch made "refactor this to Rust" inject the
|
|
418
|
+
# run-tests skill, adding irrelevant prompt weight and bad guidance.
|
|
419
|
+
for name in _predict_skill_names(user_text):
|
|
420
|
+
add(name)
|
|
421
|
+
return selected
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def dynamic_skill_candidates(
|
|
425
|
+
user_text: str,
|
|
426
|
+
*,
|
|
427
|
+
recent_tools: list[str] | None = None,
|
|
428
|
+
last_failed_tool: str | None = None,
|
|
429
|
+
) -> list[dict[str, str]]:
|
|
430
|
+
"""Explain which skill triggers were considered for telemetry."""
|
|
431
|
+
candidates: list[dict[str, str]] = []
|
|
432
|
+
if last_failed_tool:
|
|
433
|
+
candidates.append({
|
|
434
|
+
"name": _tool_to_skill_name(last_failed_tool),
|
|
435
|
+
"reason": f"last_failed_tool:{last_failed_tool}",
|
|
436
|
+
})
|
|
437
|
+
for tool in (recent_tools or [])[:3]:
|
|
438
|
+
mapped = _tool_to_skill_name(tool)
|
|
439
|
+
if mapped:
|
|
440
|
+
candidates.append({"name": mapped, "reason": f"recent_tool:{tool}"})
|
|
441
|
+
for name in _predict_skill_names(user_text):
|
|
442
|
+
candidates.append({"name": name, "reason": "user_intent"})
|
|
443
|
+
seen: set[tuple[str, str]] = set()
|
|
444
|
+
unique: list[dict[str, str]] = []
|
|
445
|
+
for item in candidates:
|
|
446
|
+
key = (item.get("name", ""), item.get("reason", ""))
|
|
447
|
+
if not key[0] or key in seen:
|
|
448
|
+
continue
|
|
449
|
+
seen.add(key)
|
|
450
|
+
unique.append(item)
|
|
451
|
+
return unique
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def dynamic_skill_block(skills: list[Skill]) -> str:
|
|
455
|
+
if not skills:
|
|
456
|
+
return ""
|
|
457
|
+
parts = ["\n\n## Selected Skills\n"]
|
|
458
|
+
for skill in skills:
|
|
459
|
+
parts.append(f"\n### {skill.name}\n{skill.body.strip()}\n")
|
|
460
|
+
return "".join(parts)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _tool_to_skill_name(tool_name: str) -> str:
|
|
464
|
+
mapping = {
|
|
465
|
+
"bash": "run-tests",
|
|
466
|
+
"grep": "locate",
|
|
467
|
+
"glob": "locate",
|
|
468
|
+
"list_files": "locate",
|
|
469
|
+
"read_file": "locate",
|
|
470
|
+
"edit_file": "edit-verified",
|
|
471
|
+
"multi_edit": "edit-verified",
|
|
472
|
+
"write_file": "edit-verified",
|
|
473
|
+
}
|
|
474
|
+
return mapping.get((tool_name or "").strip(), "")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _predict_skill_names(user_text: str) -> list[str]:
|
|
478
|
+
predicted: list[str] = []
|
|
479
|
+
lower = (user_text or "").lower()
|
|
480
|
+
for name, pattern in _AUTO_TRIGGERS:
|
|
481
|
+
if re.search(pattern, lower, re.IGNORECASE):
|
|
482
|
+
predicted.append(name)
|
|
483
|
+
return predicted
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _parse_skill_file(path: Path, origin: str) -> Skill | None:
|
|
487
|
+
"""Parse a `.md` skill file. Accepts files with or without frontmatter.
|
|
488
|
+
|
|
489
|
+
Without frontmatter, derives a minimal description from the first
|
|
490
|
+
non-empty line so older `@skill` files still register as invocable.
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
494
|
+
except Exception:
|
|
495
|
+
return None
|
|
496
|
+
# Strip BOM if present
|
|
497
|
+
if raw.startswith(""):
|
|
498
|
+
raw = raw[1:]
|
|
499
|
+
|
|
500
|
+
name = path.stem
|
|
501
|
+
m = _FRONTMATTER_RE.match(raw)
|
|
502
|
+
if m:
|
|
503
|
+
front_text, body = m.group(1), m.group(2).strip()
|
|
504
|
+
fields: dict[str, str] = {}
|
|
505
|
+
cur_key: str | None = None
|
|
506
|
+
for line in front_text.splitlines():
|
|
507
|
+
if not line.strip():
|
|
508
|
+
continue
|
|
509
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_-]*\s*:", line):
|
|
510
|
+
key, _, val = line.partition(":")
|
|
511
|
+
cur_key = key.strip()
|
|
512
|
+
fields[cur_key] = val.strip()
|
|
513
|
+
elif cur_key is not None and (line.startswith(" ") or line.startswith("\t")):
|
|
514
|
+
fields[cur_key] = (fields[cur_key] + " " + line.strip()).strip()
|
|
515
|
+
name = _clip(fields.get("name", name) or path.stem, MAX_NAME_LENGTH)
|
|
516
|
+
description = _clip(fields.get("description", ""), MAX_DESCRIPTION_LENGTH)
|
|
517
|
+
when_to_use = fields.get("when_to_use", "").strip()
|
|
518
|
+
disable_flag = _truthy(fields.get("disable-model-invocation", "")
|
|
519
|
+
or fields.get("disable_model_invocation", ""))
|
|
520
|
+
else:
|
|
521
|
+
body = raw.strip()
|
|
522
|
+
# Derive a one-line description from the first non-empty line of
|
|
523
|
+
# the body — keeps legacy no-frontmatter skills usable from the tool.
|
|
524
|
+
first_line = next((ln.strip() for ln in body.splitlines() if ln.strip()), "")
|
|
525
|
+
description = _clip(first_line, MAX_DESCRIPTION_LENGTH)
|
|
526
|
+
when_to_use = ""
|
|
527
|
+
disable_flag = False
|
|
528
|
+
|
|
529
|
+
if not description:
|
|
530
|
+
description = f"Skill '{name}' (no description provided)."
|
|
531
|
+
|
|
532
|
+
return Skill(
|
|
533
|
+
name=name,
|
|
534
|
+
description=description,
|
|
535
|
+
when_to_use=when_to_use,
|
|
536
|
+
body=body,
|
|
537
|
+
source_path=path,
|
|
538
|
+
origin=origin,
|
|
539
|
+
disable_model_invocation=disable_flag,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _parse_skill_from_string(name: str, body_with_frontmatter: str) -> Skill | None:
|
|
544
|
+
"""Parse a skill whose full markdown (frontmatter + body) lives in
|
|
545
|
+
memory rather than on disk. Kept for callers that generate skill
|
|
546
|
+
content dynamically (rare) — the primary on-disk path is
|
|
547
|
+
`_parse_skill_file`.
|
|
548
|
+
"""
|
|
549
|
+
# Reuse the file-parser by pointing source_path at a sentinel path
|
|
550
|
+
# derived from the name. This path is never read; it's just a label.
|
|
551
|
+
sentinel = Path("<builtin>") / f"{name}.md"
|
|
552
|
+
raw = body_with_frontmatter
|
|
553
|
+
if raw.startswith(""):
|
|
554
|
+
raw = raw[1:]
|
|
555
|
+
m = _FRONTMATTER_RE.match(raw)
|
|
556
|
+
if not m:
|
|
557
|
+
return None
|
|
558
|
+
front_text, body = m.group(1), m.group(2).strip()
|
|
559
|
+
fields: dict[str, str] = {}
|
|
560
|
+
cur_key: str | None = None
|
|
561
|
+
for line in front_text.splitlines():
|
|
562
|
+
if not line.strip():
|
|
563
|
+
continue
|
|
564
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_-]*\s*:", line):
|
|
565
|
+
key, _, val = line.partition(":")
|
|
566
|
+
cur_key = key.strip()
|
|
567
|
+
fields[cur_key] = val.strip()
|
|
568
|
+
elif cur_key is not None and (line.startswith(" ") or line.startswith("\t")):
|
|
569
|
+
fields[cur_key] = (fields[cur_key] + " " + line.strip()).strip()
|
|
570
|
+
skill_name = _clip(fields.get("name", name) or name, MAX_NAME_LENGTH)
|
|
571
|
+
description = _clip(fields.get("description", ""), MAX_DESCRIPTION_LENGTH)
|
|
572
|
+
when_to_use = fields.get("when_to_use", "").strip()
|
|
573
|
+
disable_flag = _truthy(fields.get("disable-model-invocation", "")
|
|
574
|
+
or fields.get("disable_model_invocation", ""))
|
|
575
|
+
if not description:
|
|
576
|
+
return None
|
|
577
|
+
return Skill(
|
|
578
|
+
name=skill_name,
|
|
579
|
+
description=description,
|
|
580
|
+
when_to_use=when_to_use,
|
|
581
|
+
body=body,
|
|
582
|
+
source_path=sentinel,
|
|
583
|
+
origin="bundled",
|
|
584
|
+
disable_model_invocation=disable_flag,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def load_registry(repo_root: Path) -> SkillRegistry:
|
|
589
|
+
"""Build a fresh registry. Resolution order: user → project → bundled.
|
|
590
|
+
|
|
591
|
+
Bundled skills are loaded directly from the package's
|
|
592
|
+
`src/localcode/skills/*.md` directory (see `_bundled_skill_dir`). Users
|
|
593
|
+
can override any bundled skill by dropping a same-name `.md` file
|
|
594
|
+
in `~/.localcode/skills/` or `{repo}/.localcode/skills/` — first-found wins.
|
|
595
|
+
"""
|
|
596
|
+
registry: dict[str, Skill] = {}
|
|
597
|
+
|
|
598
|
+
# User + project scopes win — scanned first so first-found-wins keeps them.
|
|
599
|
+
for directory in skill_dirs(repo_root):
|
|
600
|
+
if not directory.is_dir():
|
|
601
|
+
continue
|
|
602
|
+
for path in _skill_files(directory):
|
|
603
|
+
skill = _parse_skill_file(path, origin=_origin_for(directory))
|
|
604
|
+
if skill is None:
|
|
605
|
+
continue
|
|
606
|
+
if skill.name not in registry:
|
|
607
|
+
registry[skill.name] = skill
|
|
608
|
+
|
|
609
|
+
# Bundled skills as the lowest-priority tier. Any name already
|
|
610
|
+
# claimed by user/project is left alone.
|
|
611
|
+
bundled = _bundled_skill_dir()
|
|
612
|
+
if bundled.is_dir():
|
|
613
|
+
for path in sorted(bundled.glob("*.md")):
|
|
614
|
+
skill = _parse_skill_file(path, origin="bundled")
|
|
615
|
+
if skill is None or skill.name in registry:
|
|
616
|
+
continue
|
|
617
|
+
registry[skill.name] = skill
|
|
618
|
+
|
|
619
|
+
return SkillRegistry(skills=registry)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _origin_for(directory: Path) -> str:
|
|
623
|
+
s = str(directory)
|
|
624
|
+
if ".localcode/skills" in s and s.startswith(str(Path.home())):
|
|
625
|
+
return "user"
|
|
626
|
+
return "project"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def skill_tool_schema() -> dict:
|
|
630
|
+
"""Tool schema for the model-invoked `skill` tool. Drop into TOOL_SCHEMAS."""
|
|
631
|
+
return {
|
|
632
|
+
"type": "function",
|
|
633
|
+
"function": {
|
|
634
|
+
"name": "skill",
|
|
635
|
+
"description": (
|
|
636
|
+
"Invoke a named skill (recipe) to load its detailed "
|
|
637
|
+
"instructions into the conversation. Available skill "
|
|
638
|
+
"names are listed under 'Available skills' in your "
|
|
639
|
+
"system prompt. Use a skill when the user's request "
|
|
640
|
+
"matches its purpose — the skill body will guide your "
|
|
641
|
+
"next actions. Call with {\"name\": \"<skill-name>\"}."
|
|
642
|
+
),
|
|
643
|
+
"parameters": {
|
|
644
|
+
"type": "object",
|
|
645
|
+
"properties": {
|
|
646
|
+
"name": {
|
|
647
|
+
"type": "string",
|
|
648
|
+
"description": "The skill name, e.g. 'review' or 'run-tests'.",
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
"required": ["name"],
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ── Keyword auto-activation ─────────────────────────────────────────
|
|
658
|
+
#
|
|
659
|
+
# C-6 measured the model invoking `skill` voluntarily only 1/7 of the
|
|
660
|
+
# time — IQ2_M doesn't reliably pattern-match skill listings into tool
|
|
661
|
+
# calls. Rather than relying on model agency, we detect clear-intent
|
|
662
|
+
# phrases in the user's turn and auto-activate the matching skill: its
|
|
663
|
+
# body gets prepended to the model's context for that turn so it sees
|
|
664
|
+
# the recipe natively without needing to invoke the tool.
|
|
665
|
+
#
|
|
666
|
+
# Conservative patterns only — false positives cost the model ~1 KB of
|
|
667
|
+
# extra system-prompt context (cheap); false negatives leave the model
|
|
668
|
+
# on its own (we're no worse than before).
|
|
669
|
+
|
|
670
|
+
_AUTO_TRIGGERS: tuple[tuple[str, str], ...] = (
|
|
671
|
+
# (skill_name, regex pattern — case-insensitive)
|
|
672
|
+
("run-tests", r"\b(run|pass(?:ing)?)\s+(?:the\s+)?tests?\b|\bpytest\b|\bjest\b|\bcargo\s+test\b|\bgo\s+test\b|\bare\s+(?:the\s+)?tests\s+passing\b"),
|
|
673
|
+
("git-commit-safely", r"\b(git\s+)?commit(?:\s+(?:this|my|the|changes?|now))?\b|\bgit\s+add\b"),
|
|
674
|
+
("debug", r"\btraceback\b|\bfailing\s+test\b|\bexception\b|\berror:\s|\bwhy\s+(?:is|does).*(?:fail|error|break)"),
|
|
675
|
+
("review", r"\breview\s+(?:this|my|the|src|code)\b|\bcode\s+review\b|\baudit\s+(?:this|my|the)\b"),
|
|
676
|
+
("explain", r"\bexplain\s+(?:this|that|the)\b|\bwhat\s+does\s+.+\s+do\b|\bhow\s+does\s+.+\s+work\b|\bwalk\s+me\s+through\b"),
|
|
677
|
+
("locate", r"\bwhere\s+(?:is|does)\b|\bfind\s+(?:the\s+)?file\b|\bwhich\s+file\b"),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def auto_activate(user_text: str, registry: SkillRegistry) -> Skill | None:
|
|
682
|
+
"""Return the first skill whose trigger pattern matches `user_text`,
|
|
683
|
+
or None. Case-insensitive regex scan — no NLP, no embeddings.
|
|
684
|
+
|
|
685
|
+
Called once per user turn in the agent loop. On match the returned
|
|
686
|
+
skill's body should be injected as a system-role note before the
|
|
687
|
+
model's first response so it sees the recipe in-context.
|
|
688
|
+
"""
|
|
689
|
+
import re
|
|
690
|
+
if not user_text:
|
|
691
|
+
return None
|
|
692
|
+
for name, pattern in _AUTO_TRIGGERS:
|
|
693
|
+
skill = registry.get(name)
|
|
694
|
+
if skill is None:
|
|
695
|
+
continue
|
|
696
|
+
if skill.disable_model_invocation:
|
|
697
|
+
continue
|
|
698
|
+
if re.search(pattern, user_text, re.IGNORECASE):
|
|
699
|
+
return skill
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def invoke_skill(registry: SkillRegistry, name: str) -> str:
|
|
704
|
+
"""Return the skill body for tool-result injection, or an error message."""
|
|
705
|
+
skill = registry.get(name)
|
|
706
|
+
if skill is None:
|
|
707
|
+
# Only list model-invocable skills in the error — mirrors listing().
|
|
708
|
+
visible = sorted(n for n, s in registry.skills.items()
|
|
709
|
+
if not s.disable_model_invocation)
|
|
710
|
+
available = ", ".join(visible) or "(none registered)"
|
|
711
|
+
return (
|
|
712
|
+
f"Error: no skill named '{name}'. "
|
|
713
|
+
f"Available skills: {available}. "
|
|
714
|
+
f"Use the exact name from the system prompt's skill listing."
|
|
715
|
+
)
|
|
716
|
+
if skill.disable_model_invocation:
|
|
717
|
+
return (
|
|
718
|
+
f"Error: skill '{name}' is marked disable-model-invocation "
|
|
719
|
+
f"and can only be invoked by the user via @{name}."
|
|
720
|
+
)
|
|
721
|
+
header = f"# Skill: {skill.name}"
|
|
722
|
+
if skill.when_to_use:
|
|
723
|
+
header += f"\n(When to use: {skill.when_to_use})"
|
|
724
|
+
return (
|
|
725
|
+
f"{header}\n\n{skill.body}\n\n"
|
|
726
|
+
f"---\nFollow the instructions above to complete the user's request. "
|
|
727
|
+
f"Use your other tools (read_file, write_file, bash, etc.) as needed."
|
|
728
|
+
)
|