autoforge-agent 0.4.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.
Files changed (70) hide show
  1. autoforge/__init__.py +87 -0
  2. autoforge/__main__.py +16 -0
  3. autoforge/agent.py +2971 -0
  4. autoforge/autonomy/__init__.py +14 -0
  5. autoforge/autonomy/confirm.py +130 -0
  6. autoforge/autonomy/policy.py +245 -0
  7. autoforge/autonomy/roles.py +191 -0
  8. autoforge/autonomy/selfmod.py +146 -0
  9. autoforge/autonomy/spawn.py +227 -0
  10. autoforge/autonomy/topology.py +235 -0
  11. autoforge/browser.py +924 -0
  12. autoforge/cli.py +1119 -0
  13. autoforge/configfile.py +86 -0
  14. autoforge/core/__init__.py +29 -0
  15. autoforge/core/agent.py +271 -0
  16. autoforge/core/compaction.py +765 -0
  17. autoforge/core/lineedit.py +678 -0
  18. autoforge/core/llm.py +324 -0
  19. autoforge/core/message.py +89 -0
  20. autoforge/core/steering.py +280 -0
  21. autoforge/cpu/__init__.py +99 -0
  22. autoforge/cpu/kernel.py +442 -0
  23. autoforge/cpu/ops.py +652 -0
  24. autoforge/cpu/probe.py +569 -0
  25. autoforge/cpu/safety.py +463 -0
  26. autoforge/cpu/tune.py +564 -0
  27. autoforge/ecosystem.py +485 -0
  28. autoforge/forge/__init__.py +21 -0
  29. autoforge/forge/adversary.py +206 -0
  30. autoforge/forge/evolution.py +476 -0
  31. autoforge/forge/fuzzer.py +328 -0
  32. autoforge/forge/generator.py +591 -0
  33. autoforge/forge/invariance.py +369 -0
  34. autoforge/forge/metacog.py +177 -0
  35. autoforge/forge/pipeline.py +304 -0
  36. autoforge/forge/sandbox.py +301 -0
  37. autoforge/forge/validity.py +608 -0
  38. autoforge/forge/verifier.py +365 -0
  39. autoforge/gpu/__init__.py +116 -0
  40. autoforge/gpu/backend_triton.py +421 -0
  41. autoforge/gpu/bench.py +30 -0
  42. autoforge/gpu/kernel.py +430 -0
  43. autoforge/gpu/ops.py +349 -0
  44. autoforge/gpu/probe.py +267 -0
  45. autoforge/gpu/safety.py +248 -0
  46. autoforge/gpu/units.py +39 -0
  47. autoforge/mcp.py +1050 -0
  48. autoforge/measure.py +368 -0
  49. autoforge/modes.py +299 -0
  50. autoforge/notify.py +339 -0
  51. autoforge/route/__init__.py +3 -0
  52. autoforge/route/router.py +217 -0
  53. autoforge/schedule.py +427 -0
  54. autoforge/setup_wizard.py +232 -0
  55. autoforge/skills.py +496 -0
  56. autoforge/store.py +611 -0
  57. autoforge/timing.py +375 -0
  58. autoforge/tools/__init__.py +4 -0
  59. autoforge/tools/composition.py +143 -0
  60. autoforge/tools/registry.py +392 -0
  61. autoforge/tools/spec.py +230 -0
  62. autoforge/vision.py +279 -0
  63. autoforge/web/__init__.py +5 -0
  64. autoforge/web/server.py +689 -0
  65. autoforge/webtools.py +1382 -0
  66. autoforge_agent-0.4.0.dist-info/METADATA +477 -0
  67. autoforge_agent-0.4.0.dist-info/RECORD +70 -0
  68. autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
  69. autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
  70. autoforge_agent-0.4.0.dist-info/top_level.txt +1 -0
autoforge/skills.py ADDED
@@ -0,0 +1,496 @@
1
+ """Skills: procedural knowledge the agent keeps as files and finds by use.
2
+
3
+ Tools answer "what can be done here". Skills answer "how this is done here" —
4
+ the sequence, the pitfalls, the thing that went wrong last time. The agent had
5
+ plenty of the first and none of the second, which is why it re-derived the same
6
+ approach every session and could not tell a procedure it had run forty times
7
+ from one it had never tried.
8
+
9
+ Two decisions shape everything below.
10
+
11
+ *The files own the content.* A skill is markdown with a frontmatter header, on
12
+ disk, in a directory a human can open and edit. No part of a skill's text lives
13
+ only in the database, so a skill outlives the agent that wrote it.
14
+
15
+ *The database owns the history.* `loads` counts how often the agent actually
16
+ opened a skill. That counter is what makes retrieval behavioural rather than
17
+ lexical: a procedure that keeps getting reached for outranks one that merely
18
+ reads as relevant. The counter is written by the act of loading, so it cannot
19
+ drift away from what happened.
20
+
21
+ Progressive disclosure: the prompt carries a menu — name, when-to-use, load
22
+ count — and never the bodies. A library of forty skills costs forty lines, and
23
+ the agent pays for a body only when it decides to read one. That decision is
24
+ the retrieval event, which is why the menu is not a summary of the library but
25
+ the index of it.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any, Iterator
34
+
35
+ from .store import _default_home
36
+
37
+ SKILL_SUFFIX = ".md"
38
+ ARCHIVE_DIR = ".archive"
39
+ ENV_DIRS = "AUTOFORGE_SKILLS_DIRS"
40
+
41
+ #: A skill package's entry point. The library is a tree of categories, each
42
+ #: holding skill directories, each holding one of these — the layout every
43
+ #: skill on this machine actually uses.
44
+ SKILL_INDEX_NAME = "SKILL.md"
45
+
46
+ #: Directories a skill scan must never descend into. Ported from Hermes'
47
+ #: `skill_utils.EXCLUDED_SKILL_DIRS`, which is the implementation that already
48
+ #: reads this exact directory tree correctly: VCS and editor metadata, virtualenvs
49
+ #: and dependency trees, and every cache directory a Python project grows.
50
+ EXCLUDED_SKILL_DIRS = frozenset({
51
+ ".git", ".github", ".hub", ".archive", ".venv", "venv",
52
+ "node_modules", "site-packages", "__pycache__",
53
+ ".tox", ".nox", ".pytest_cache", ".mypy_cache", ".ruff_cache",
54
+ })
55
+
56
+ #: Progressive-disclosure areas *inside a skill package*: loaded explicitly by
57
+ #: name, never discovered as skills of their own. Pruned only when the directory
58
+ #: holding them is itself a skill (see `iter_skill_index_files`) — otherwise a
59
+ #: legitimate category called `scripts/` or `templates/` would vanish.
60
+ SKILL_SUPPORT_DIRS = frozenset({"references", "templates", "assets", "scripts"})
61
+
62
+ MENU_LINE_CHARS = 160 # per-entry cap in the prompt menu
63
+ MENU_BUDGET_CHARS = 1400 # whole-menu cap, for the same reason memory has one
64
+
65
+
66
+ class SkillError(ValueError):
67
+ """A skill file that cannot be used as written."""
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ @dataclass
72
+ class Skill:
73
+ """One skill, as read from disk, with its usage history from the store."""
74
+
75
+ name: str
76
+ description: str
77
+ when_to_use: str
78
+ body: str
79
+ path: str
80
+ source: str = "user"
81
+ tags: list[str] = field(default_factory=list)
82
+ loads: int = 0
83
+ last_loaded: float | None = None
84
+
85
+ def menu_line(self) -> str:
86
+ """One line for the prompt: what it is, when to reach for it, how proven.
87
+
88
+ The load count is shown because it is real information the agent can act
89
+ on: a procedure used twenty times is a different bet from one that has
90
+ never run. Hiding it would make the menu look like an equal menu.
91
+ """
92
+ when = self.when_to_use or self.description
93
+ if len(when) > MENU_LINE_CHARS:
94
+ when = when[:MENU_LINE_CHARS].rstrip() + " ..."
95
+ if self.loads:
96
+ use = f"used {self.loads}x"
97
+ else:
98
+ use = "never used"
99
+ return f" {self.name} ({use}) -- {when}"
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return {
103
+ "name": self.name, "description": self.description,
104
+ "when_to_use": self.when_to_use, "tags": list(self.tags),
105
+ "source": self.source, "path": self.path, "loads": self.loads,
106
+ "last_loaded": self.last_loaded, "body_chars": len(self.body),
107
+ }
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # frontmatter -- hand-parsed, no dependency, and strict enough to catch a file
112
+ # that would otherwise be silently misread as having no description.
113
+ # ---------------------------------------------------------------------------
114
+ def split_frontmatter(text: str, *, origin: str = "") -> tuple[dict[str, str], str]:
115
+ """Split `---`-fenced frontmatter from the body.
116
+
117
+ Raises SkillError rather than guessing when the fence is missing or
118
+ unterminated: a file whose header was silently ignored would present as a
119
+ skill with no description, which is a routing bug that looks like a content
120
+ bug, and those take an afternoon to tell apart.
121
+ """
122
+ lines = text.splitlines()
123
+ where = f" ({origin})" if origin else ""
124
+ if not lines or lines[0].strip() != "---":
125
+ raise SkillError(
126
+ f"skill{where} does not start with a '---' frontmatter line")
127
+ for i in range(1, len(lines)):
128
+ if lines[i].strip() == "---":
129
+ return _parse_meta(lines[1:i], origin=origin), _join_body(lines[i + 1:])
130
+ raise SkillError(f"skill{where} has frontmatter with no closing '---'")
131
+
132
+
133
+ def _join_body(lines: list[str]) -> str:
134
+ return "\n".join(lines).strip("\n")
135
+
136
+
137
+ def _parse_meta(lines: list[str], *, origin: str = "") -> dict[str, str]:
138
+ meta: dict[str, str] = {}
139
+ where = f" ({origin})" if origin else ""
140
+ for raw in lines:
141
+ line = raw.strip()
142
+ if not line or line.startswith("#"):
143
+ continue
144
+ if ":" not in line:
145
+ raise SkillError(
146
+ f"skill{where} frontmatter line is not 'key: value': {raw!r}")
147
+ key, _, value = line.partition(":")
148
+ k = key.strip().lower().replace("-", "_")
149
+ v = value.strip()
150
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
151
+ v = v[1:-1] # quoted value: strip the quotes
152
+ meta[k] = v
153
+ if not meta.get("description"):
154
+ raise SkillError(f"skill{where} has no description, so it cannot be routed")
155
+ return meta
156
+
157
+
158
+ def parse_tags(raw: str) -> list[str]:
159
+ """Tags are comma-separated, because that is what a human types."""
160
+ return [t.strip() for t in (raw or "").split(",") if t.strip()]
161
+
162
+
163
+ def render_skill_text(name: str, description: str, when_to_use: str,
164
+ tags: list[str], body: str) -> str:
165
+ """Serialise a skill back to the on-disk format. Round-trips through parse."""
166
+ lines = ["---", f"name: {name}", f"description: {description}"]
167
+ if when_to_use:
168
+ lines.append(f"when_to_use: {when_to_use}")
169
+ if tags:
170
+ lines.append("tags: " + ", ".join(tags))
171
+ lines.append("---")
172
+ lines.append("")
173
+ lines.append(body.rstrip() or f"# {name}")
174
+ return "\n".join(lines) + "\n"
175
+
176
+
177
+ def valid_name(name: str) -> bool:
178
+ """Lower-case, hyphens/underscores/digits — a filename a human can type.
179
+
180
+ Enforced rather than sanitised: silently rewriting `My Skill!` into
181
+ `my-skill` would leave the agent unable to find the skill it just wrote,
182
+ which reads as a broken write rather than as a rejected name.
183
+ """
184
+ if not name or len(name) > 64:
185
+ return False
186
+ if name[0] == "-" or name[0] == "_":
187
+ return False
188
+ return all(c.islower() or c.isdigit() or c in "-_" for c in name)
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ def default_skill_dirs(cwd: str | None = None, home: str | None = None
193
+ ) -> list[tuple[str, str]]:
194
+ """Where skills are looked for, most specific first: (source, path).
195
+
196
+ Project before user, because a project's conventions should beat a general
197
+ one of the same name — and a shadowed skill is reported rather than hidden,
198
+ so "why is my edit not taking effect" has an answer on the first look.
199
+ """
200
+ env = os.environ.get(ENV_DIRS)
201
+ if env:
202
+ out = []
203
+ for i, part in enumerate(env.split(os.pathsep)):
204
+ if part.strip():
205
+ out.append((f"env{i}" if i else "env", os.path.abspath(part.strip())))
206
+ return out
207
+ home = home or _default_home()
208
+ cwd = cwd or os.getcwd()
209
+ dirs = [(("project"), os.path.join(os.path.abspath(cwd), "skills"))]
210
+ dirs.append(("user", os.path.join(home, "skills")))
211
+ return dirs
212
+
213
+
214
+ def iter_skill_index_files(directory: str | Path) -> Iterator[Path]:
215
+ """Walk `directory` yielding sorted `SKILL.md` paths, pruning as it goes.
216
+
217
+ Ported from Hermes' `skill_utils.iter_skill_index_files` because that is the
218
+ scanner that already reads this machine's skill tree correctly, and this
219
+ module's first version did not: it globbed `*.md` one level deep, which sees
220
+ zero of the 243 skills on disk. Every skill here is a *package*
221
+ (`<category>/<name>/SKILL.md`), and a flat glob cannot see a tree.
222
+
223
+ Pruning happens on the directory list rather than on the results, so
224
+ excluded subtrees are never even listed — the difference between reading a
225
+ tree and filtering a mess.
226
+
227
+ The support-dir rule carries the subtlety worth keeping: `references/`,
228
+ `templates/`, `assets/` and `scripts/` are cut *only* when the directory
229
+ holding them is itself a skill (`SKILL.md` present). A category that happens
230
+ to be named `scripts` stays discoverable, and a skill's own support files —
231
+ which may well contain an archived `SKILL.md` package — are not promoted to
232
+ skills.
233
+ """
234
+ root = str(directory)
235
+ matches: list[str] = []
236
+ for here, dirs, files in os.walk(root, followlinks=True):
237
+ is_a_skill = SKILL_INDEX_NAME in files
238
+ dirs[:] = [
239
+ d for d in dirs
240
+ if d not in EXCLUDED_SKILL_DIRS
241
+ and not (is_a_skill and d in SKILL_SUPPORT_DIRS)
242
+ ]
243
+ if SKILL_INDEX_NAME in files:
244
+ matches.append(os.path.join(here, SKILL_INDEX_NAME))
245
+ for path in sorted(matches):
246
+ yield Path(path)
247
+
248
+
249
+ def iter_skill_files(directory: str | Path) -> Iterator[Path]:
250
+ """Every skill file under `directory`, in both layouts this library supports.
251
+
252
+ 1. Flat `<name>.md` at the top level — what `SkillLibrary.write` produces,
253
+ and therefore what the agent's own skills look like.
254
+ 2. `<category>/<name>/SKILL.md` anywhere below — the package layout, which is
255
+ how skills are distributed and how the whole library on this machine is
256
+ arranged.
257
+
258
+ Flat first, so a skill the agent wrote itself outranks a packaged one of the
259
+ same name — the agent's own edit is the more specific statement of intent.
260
+ `SkillLibrary.scan` reports the collision either way, so this order decides
261
+ which file wins, not whether the loser is noticed.
262
+ """
263
+ root = Path(directory)
264
+ for path in sorted(root.glob("*" + SKILL_SUFFIX)):
265
+ # A half-written file from an interrupted `write` (see the temp-then-move
266
+ # dance there) must never be indexed.
267
+ if path.name.endswith(SKILL_SUFFIX + ".tmp"):
268
+ continue
269
+ yield path
270
+ yield from iter_skill_index_files(root)
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ class SkillLibrary:
275
+ """Skills on disk, plus the usage history that makes them rankable."""
276
+
277
+ def __init__(self, store: Any = None, dirs: list[tuple[str, str]] | None = None,
278
+ cwd: str | None = None) -> None:
279
+ self.store = store
280
+ self.dirs = dirs if dirs is not None else default_skill_dirs(cwd=cwd)
281
+ self._skills: dict[str, Skill] = {}
282
+ self.errors: list[str] = [] # unreadable files, with the reason
283
+ self.shadowed: list[tuple[str, str]] = [] # (name, winning path)
284
+
285
+ # -- reading ----------------------------------------------------------
286
+ def dirs_present(self) -> list[tuple[str, str]]:
287
+ return [(s, p) for s, p in self.dirs if os.path.isdir(p)]
288
+
289
+ def scan(self) -> list[Skill]:
290
+ """Read every skill directory. Content comes from disk, always.
291
+
292
+ Errors are collected, never raised: one malformed file among forty must
293
+ not cost the agent the other thirty-nine, and a silent skip would let
294
+ the agent believe it has no skill for a task it has one for.
295
+ """
296
+ found: dict[str, Skill] = {}
297
+ self.errors = []
298
+ self.shadowed = []
299
+ for source, directory in self.dirs:
300
+ if not os.path.isdir(directory):
301
+ continue
302
+ # Both layouts, and a tree walk rather than a flat glob: every skill
303
+ # is a package under a category, and the previous `glob("*.md")` saw
304
+ # none of them. See `iter_skill_files`.
305
+ for path in iter_skill_files(directory):
306
+ try:
307
+ skill = self._read(path, source)
308
+ except SkillError as exc:
309
+ self.errors.append(str(exc))
310
+ continue
311
+ except OSError as exc:
312
+ self.errors.append(f"skill {path} is unreadable: {exc}")
313
+ continue
314
+ prior = found.get(skill.name)
315
+ if prior is not None:
316
+ self.shadowed.append((skill.name, prior.path))
317
+ continue
318
+ found[skill.name] = skill
319
+
320
+ # usage history is attached last, so a load count can never be
321
+ # overwritten by whatever the file happens to say about itself.
322
+ if self.store is not None:
323
+ known = {r["name"]: r for r in self.store.skill_rows()}
324
+ for name, skill in found.items():
325
+ row = known.get(name)
326
+ if row is not None:
327
+ skill.loads = int(row["loads"])
328
+ skill.last_loaded = row["last_loaded"]
329
+ self.store.upsert_skill(
330
+ skill.name, skill.path, skill.source, skill.description,
331
+ skill.when_to_use, skill.tags)
332
+ for stale in set(known) - set(found):
333
+ self.store.forget_skill_row(stale)
334
+ self._skills = found
335
+ return self.all()
336
+
337
+ def _read(self, path: Path, source: str) -> Skill:
338
+ text = path.read_text(encoding="utf-8")
339
+ meta, body = split_frontmatter(text, origin=str(path))
340
+ name = (meta.get("name") or path.stem).strip()
341
+ if not valid_name(name):
342
+ raise SkillError(
343
+ f"skill {path} has an unusable name {name!r}: use lower-case "
344
+ "letters, digits, hyphens or underscores")
345
+ return Skill(
346
+ name=name, description=meta["description"],
347
+ when_to_use=meta.get("when_to_use", ""), body=body, path=str(path),
348
+ source=source, tags=parse_tags(meta.get("tags", "")),
349
+ )
350
+
351
+ def all(self) -> list[Skill]:
352
+ """Every skill, most-used first — the order the router blends on."""
353
+ return sorted(self._skills.values(),
354
+ key=lambda s: (-s.loads, s.name))
355
+
356
+ def get(self, name: str) -> Skill | None:
357
+ return self._skills.get(name)
358
+
359
+ def load(self, name: str) -> Skill | None:
360
+ """Read a skill whole, and count that as the retrieval event it is.
361
+
362
+ The counter is bumped here and not by `scan`, so "used" means the agent
363
+ actually opened it. A menu that scores a skill it never reads would be
364
+ measuring the library, not the agent.
365
+ """
366
+ skill = self._skills.get(name)
367
+ if skill is None:
368
+ return None
369
+ if self.store is not None:
370
+ n = self.store.skill_load(name)
371
+ if n:
372
+ skill.loads = n
373
+ skill.last_loaded = time.time()
374
+ return skill
375
+
376
+ # -- writing ----------------------------------------------------------
377
+ def _target_dir(self, name: str, scope: str) -> str:
378
+ """Where a write for `name` goes: in place if it exists, else `scope`.
379
+
380
+ Editing in place is deliberate. Writing a same-named skill into a
381
+ different directory would shadow the file the agent just read, and the
382
+ symptom — "I edited it and nothing changed" — points at everything
383
+ except the cause.
384
+ """
385
+ existing = self._skills.get(name)
386
+ if existing is not None:
387
+ return os.path.dirname(existing.path)
388
+ for source, directory in reversed(self.dirs):
389
+ if source == scope:
390
+ return directory
391
+ return self.dirs[-1][1]
392
+
393
+ def write(self, name: str, description: str, when_to_use: str = "",
394
+ body: str = "", tags: list[str] | None = None,
395
+ scope: str = "user") -> Skill:
396
+ """Create or update a skill file, and index it straight away."""
397
+ if not valid_name(name):
398
+ raise SkillError(
399
+ f"unusable skill name {name!r}: lower-case letters, digits, "
400
+ "hyphens or underscores, starting with a letter or digit")
401
+ if not (description or "").strip():
402
+ raise SkillError("a skill needs a description or it cannot be routed")
403
+ target_dir = self._target_dir(name, scope)
404
+ os.makedirs(target_dir, exist_ok=True)
405
+ path = os.path.join(target_dir, name + SKILL_SUFFIX)
406
+ existing = self._skills.get(name)
407
+ text = render_skill_text(name, description.strip(), when_to_use.strip(),
408
+ list(tags or []), body)
409
+ # write to a sibling then move, so a crash mid-write cannot leave a
410
+ # half-file where a skill used to be.
411
+ tmp = path + ".tmp"
412
+ with open(tmp, "w", encoding="utf-8", newline="") as fh:
413
+ fh.write(text)
414
+ os.replace(tmp, path)
415
+ skill = self._read(Path(path), existing.source if existing else scope)
416
+ if existing is not None:
417
+ skill.loads = existing.loads
418
+ skill.last_loaded = existing.last_loaded
419
+ self._skills[name] = skill
420
+ if self.store is not None:
421
+ self.store.upsert_skill(skill.name, skill.path, skill.source,
422
+ skill.description, skill.when_to_use,
423
+ skill.tags)
424
+ return skill
425
+
426
+ def archive(self, name: str) -> str:
427
+ """Move a skill out of the library without destroying it.
428
+
429
+ Archive rather than delete: a skill is a written-down procedure whose
430
+ value is precisely that it outlives the moment it was written. Its row
431
+ goes, because a retired skill that still ranks is worse than one that
432
+ never existed. A clashing archive name gets a timestamp, never an
433
+ overwrite.
434
+ """
435
+ skill = self._skills.get(name)
436
+ if skill is None:
437
+ raise SkillError(f"no skill named {name!r}")
438
+ src = Path(skill.path)
439
+ dest_dir = src.parent / ARCHIVE_DIR
440
+ dest_dir.mkdir(parents=True, exist_ok=True)
441
+ dest = dest_dir / src.name
442
+ if dest.exists():
443
+ dest = dest_dir / f"{src.stem}-{int(time.time())}{src.suffix}"
444
+ os.replace(src, dest)
445
+ self._skills.pop(name, None)
446
+ if self.store is not None:
447
+ self.store.forget_skill_row(name)
448
+ return str(dest)
449
+
450
+ # -- prompt surface ---------------------------------------------------
451
+ def menu(self, budget: int = MENU_BUDGET_CHARS) -> list[str]:
452
+ """The index lines that ride in every prompt. Bodies never do.
453
+
454
+ Bounded, and it says when it truncated: an agent that cannot see its
455
+ own menu was cut will conclude it has no procedure for a task it does
456
+ have one for, which is the failure this whole module exists to fix.
457
+ """
458
+ skills = self.all()
459
+ if not skills:
460
+ return ["- Skills: none yet (skill_write(name, description, body) "
461
+ "saves one)."]
462
+ lines = ["- Skills (procedures; skill_view(name) reads one in full):"]
463
+ shown = 0
464
+ used = 0
465
+ for skill in skills:
466
+ line = skill.menu_line()
467
+ if shown and used + len(line) > budget:
468
+ break
469
+ lines.append(line)
470
+ used += len(line)
471
+ shown += 1
472
+ rest = len(skills) - shown
473
+ if rest:
474
+ lines.append(f" (+{rest} more -- skill_list reads them all)")
475
+ return lines
476
+
477
+ def report(self) -> dict[str, Any]:
478
+ """Facts for the agent's self-model, measured rather than asserted."""
479
+ skills = self.all()
480
+ return {
481
+ "skills": len(skills),
482
+ "loads_total": sum(s.loads for s in skills),
483
+ "never_loaded": [s.name for s in skills if not s.loads],
484
+ "most_used": [s.name for s in skills[:3] if s.loads],
485
+ "dirs": [d for _, d in self.dirs_present()],
486
+ "unreadable": list(self.errors),
487
+ "shadowed": [name for name, _ in self.shadowed],
488
+ "backend": "markdown on disk; usage in the store",
489
+ }
490
+
491
+
492
+ __all__ = [
493
+ "Skill", "SkillLibrary", "SkillError", "default_skill_dirs",
494
+ "render_skill_text", "split_frontmatter", "valid_name",
495
+ "parse_tags", "SKILL_SUFFIX", "ARCHIVE_DIR", "MENU_BUDGET_CHARS",
496
+ ]