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.
- autoforge/__init__.py +87 -0
- autoforge/__main__.py +16 -0
- autoforge/agent.py +2971 -0
- autoforge/autonomy/__init__.py +14 -0
- autoforge/autonomy/confirm.py +130 -0
- autoforge/autonomy/policy.py +245 -0
- autoforge/autonomy/roles.py +191 -0
- autoforge/autonomy/selfmod.py +146 -0
- autoforge/autonomy/spawn.py +227 -0
- autoforge/autonomy/topology.py +235 -0
- autoforge/browser.py +924 -0
- autoforge/cli.py +1119 -0
- autoforge/configfile.py +86 -0
- autoforge/core/__init__.py +29 -0
- autoforge/core/agent.py +271 -0
- autoforge/core/compaction.py +765 -0
- autoforge/core/lineedit.py +678 -0
- autoforge/core/llm.py +324 -0
- autoforge/core/message.py +89 -0
- autoforge/core/steering.py +280 -0
- autoforge/cpu/__init__.py +99 -0
- autoforge/cpu/kernel.py +442 -0
- autoforge/cpu/ops.py +652 -0
- autoforge/cpu/probe.py +569 -0
- autoforge/cpu/safety.py +463 -0
- autoforge/cpu/tune.py +564 -0
- autoforge/ecosystem.py +485 -0
- autoforge/forge/__init__.py +21 -0
- autoforge/forge/adversary.py +206 -0
- autoforge/forge/evolution.py +476 -0
- autoforge/forge/fuzzer.py +328 -0
- autoforge/forge/generator.py +591 -0
- autoforge/forge/invariance.py +369 -0
- autoforge/forge/metacog.py +177 -0
- autoforge/forge/pipeline.py +304 -0
- autoforge/forge/sandbox.py +301 -0
- autoforge/forge/validity.py +608 -0
- autoforge/forge/verifier.py +365 -0
- autoforge/gpu/__init__.py +116 -0
- autoforge/gpu/backend_triton.py +421 -0
- autoforge/gpu/bench.py +30 -0
- autoforge/gpu/kernel.py +430 -0
- autoforge/gpu/ops.py +349 -0
- autoforge/gpu/probe.py +267 -0
- autoforge/gpu/safety.py +248 -0
- autoforge/gpu/units.py +39 -0
- autoforge/mcp.py +1050 -0
- autoforge/measure.py +368 -0
- autoforge/modes.py +299 -0
- autoforge/notify.py +339 -0
- autoforge/route/__init__.py +3 -0
- autoforge/route/router.py +217 -0
- autoforge/schedule.py +427 -0
- autoforge/setup_wizard.py +232 -0
- autoforge/skills.py +496 -0
- autoforge/store.py +611 -0
- autoforge/timing.py +375 -0
- autoforge/tools/__init__.py +4 -0
- autoforge/tools/composition.py +143 -0
- autoforge/tools/registry.py +392 -0
- autoforge/tools/spec.py +230 -0
- autoforge/vision.py +279 -0
- autoforge/web/__init__.py +5 -0
- autoforge/web/server.py +689 -0
- autoforge/webtools.py +1382 -0
- autoforge_agent-0.4.0.dist-info/METADATA +477 -0
- autoforge_agent-0.4.0.dist-info/RECORD +70 -0
- autoforge_agent-0.4.0.dist-info/WHEEL +5 -0
- autoforge_agent-0.4.0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|