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.
Files changed (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. 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
+ )