chad-code 0.1.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.
chad/session.py ADDED
@@ -0,0 +1,281 @@
1
+ """Conversation persistence so a session survives across runs (`cli.py --continue`,
2
+ `--resume`, and the TUI `/resume` picker).
3
+
4
+ Native Claude Code records every conversation, can list them, and can fork one; this is
5
+ the local analogue, scoped per project directory (you almost always want to continue the
6
+ work for *this* repo). Only the message list is persisted as JSON — NOT the KV cache: the
7
+ next turn re-prefills the restored transcript (the system-prefix warm cache still
8
+ applies), which is the price of a cold resume. Best-effort throughout: a failure to save
9
+ or load never breaks a turn.
10
+
11
+ Layout (plan 043):
12
+
13
+ ~/.chad/sessions/<cwdhash>/<session_id>.json one file per session
14
+ ~/.chad/sessions/<cwdhash>/index.json title/updated/turns per session
15
+
16
+ `session_id` = `YYYYMMDD-HHMMSS-<4 hex>`, minted at Agent construction. Each save
17
+ overwrites only *its own* session file, so resuming a session (which mints a fresh id and
18
+ seeds the old messages) never rewrites the original — every resume is implicitly a fork.
19
+ A legacy single-slot `~/.chad/sessions/<cwdhash>.json` file is adopted as one session the
20
+ first time the directory is listed. Retention keeps the newest N per cwd, pruned on save.
21
+ """
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import secrets
26
+ import time
27
+
28
+ SESS_DIR = os.path.expanduser("~/.chad/sessions")
29
+ RETAIN = 20 # keep the newest N sessions per cwd; prune older on save
30
+ _TITLE_LEN = 60 # first-user-message title truncation for the index / picker
31
+
32
+
33
+ def _key(cwd: str) -> str:
34
+ return hashlib.sha1(os.path.abspath(cwd).encode("utf-8", "ignore")).hexdigest()[:16]
35
+
36
+
37
+ def _dir(cwd: str) -> str:
38
+ """Per-cwd session directory."""
39
+ return os.path.join(SESS_DIR, _key(cwd))
40
+
41
+
42
+ def _legacy_path(cwd: str) -> str:
43
+ """The pre-043 single-slot file (`<cwdhash>.json`), adopted on first listing."""
44
+ return os.path.join(SESS_DIR, _key(cwd) + ".json")
45
+
46
+
47
+ def _session_path(cwd: str, session_id: str) -> str:
48
+ return os.path.join(_dir(cwd), session_id + ".json")
49
+
50
+
51
+ def _index_path(cwd: str) -> str:
52
+ return os.path.join(_dir(cwd), "index.json")
53
+
54
+
55
+ def new_session_id() -> str:
56
+ """A fresh session id: sortable timestamp + 4 random hex (collision-safe within a
57
+ second). Minted at Agent construction; a resume mints a new one → implicit fork."""
58
+ return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
59
+
60
+
61
+ def _atomic_write_json(path: str, obj) -> bool:
62
+ """Write `obj` as JSON to `path` atomically at 0600. Returns True on success.
63
+
64
+ Create 0600 from the start so the conversation store (full tool args and results) is
65
+ never briefly world-readable; os.replace preserves the mode and is atomic on POSIX —
66
+ a reader never sees a half-written file."""
67
+ tmp = path + f".{os.getpid()}.tmp"
68
+ try:
69
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
70
+ with os.fdopen(fd, "w") as f:
71
+ json.dump(obj, f)
72
+ os.replace(tmp, path)
73
+ return True
74
+ except OSError:
75
+ try:
76
+ os.remove(tmp)
77
+ except OSError:
78
+ pass
79
+ return False
80
+
81
+
82
+ def _load_path(path: str):
83
+ """Return a saved {cwd, updated, meta, messages, session_id} dict, or None."""
84
+ if not os.path.isfile(path):
85
+ return None
86
+ try:
87
+ with open(path) as f:
88
+ data = json.load(f)
89
+ if isinstance(data, dict) and isinstance(data.get("messages"), list):
90
+ return data
91
+ except (OSError, json.JSONDecodeError, ValueError):
92
+ pass
93
+ return None
94
+
95
+
96
+ def _first_user_title(messages: list, limit: int = _TITLE_LEN) -> str:
97
+ """The first user message, whitespace-collapsed and truncated — the session title."""
98
+ for m in messages:
99
+ if m.get("role") == "user":
100
+ c = m.get("content")
101
+ if isinstance(c, str) and c.strip():
102
+ t = " ".join(c.split())
103
+ return t[:limit] + ("…" if len(t) > limit else "")
104
+ return ""
105
+
106
+
107
+ def _turns(messages: list) -> int:
108
+ return sum(1 for m in messages if m.get("role") == "user")
109
+
110
+
111
+ # -- index (avoids parsing every session file just to list them) -------------
112
+
113
+ def _load_index(cwd: str) -> dict:
114
+ """The per-cwd index, or an empty one if missing/corrupt (corrupt is tolerated —
115
+ `list_sessions` rebuilds it from the session files on disk)."""
116
+ try:
117
+ with open(_index_path(cwd)) as f:
118
+ data = json.load(f)
119
+ if isinstance(data, dict) and isinstance(data.get("sessions"), dict):
120
+ return data
121
+ except (OSError, json.JSONDecodeError, ValueError):
122
+ pass
123
+ return {"sessions": {}}
124
+
125
+
126
+ def _write_index(cwd: str, sessions: dict) -> None:
127
+ _atomic_write_json(_index_path(cwd), {"sessions": sessions})
128
+
129
+
130
+ def _update_index(cwd: str, session_id: str, messages: list, updated: float) -> None:
131
+ idx = _load_index(cwd)
132
+ idx["sessions"][session_id] = {
133
+ "title": _first_user_title(messages),
134
+ "updated": updated,
135
+ "turns": _turns(messages),
136
+ }
137
+ _write_index(cwd, idx["sessions"])
138
+
139
+
140
+ def _adopt_legacy(cwd: str) -> None:
141
+ """Migrate a pre-043 `<cwdhash>.json` file into the sessioned store as one session,
142
+ then remove it so it is adopted exactly once. Best-effort."""
143
+ legacy = _legacy_path(cwd)
144
+ if not os.path.isfile(legacy):
145
+ return
146
+ try:
147
+ data = _load_path(legacy)
148
+ os.makedirs(_dir(cwd), exist_ok=True)
149
+ if data:
150
+ updated = data.get("updated") or time.time()
151
+ # Derive a stable id from the legacy file's own timestamp so re-listing is
152
+ # idempotent even if the write below races; -0000 marks the adopted slot.
153
+ sid = time.strftime("%Y%m%d-%H%M%S", time.localtime(updated)) + "-0000"
154
+ target = _session_path(cwd, sid)
155
+ if not os.path.exists(target):
156
+ _atomic_write_json(target, {
157
+ "cwd": data.get("cwd", os.path.abspath(cwd)),
158
+ "session_id": sid,
159
+ "updated": updated,
160
+ "meta": data.get("meta", {}),
161
+ "messages": data["messages"],
162
+ })
163
+ _update_index(cwd, sid, data["messages"], updated)
164
+ os.remove(legacy)
165
+ except OSError:
166
+ pass
167
+
168
+
169
+ def list_sessions(cwd: str, limit: int = None) -> list:
170
+ """Sessions for `cwd`, newest first: `[{session_id, title, updated, turns}, ...]`.
171
+
172
+ Adopts a legacy file first, then reconciles the index against the files actually on
173
+ disk — so a corrupt/lost index is rebuilt and orphaned index rows are dropped. Cheap:
174
+ the common path reads only the index."""
175
+ _adopt_legacy(cwd)
176
+ sessions = _load_index(cwd)["sessions"]
177
+ try:
178
+ names = os.listdir(_dir(cwd))
179
+ except OSError:
180
+ names = []
181
+ on_disk = set()
182
+ changed = False
183
+ for name in names:
184
+ if name == "index.json" or not name.endswith(".json") or name.endswith(".tmp"):
185
+ continue
186
+ sid = name[: -len(".json")]
187
+ on_disk.add(sid)
188
+ if sid not in sessions: # file present but unindexed (corrupt index / crash)
189
+ data = _load_path(_session_path(cwd, sid))
190
+ if data:
191
+ sessions[sid] = {
192
+ "title": _first_user_title(data["messages"]),
193
+ "updated": data.get("updated", 0),
194
+ "turns": _turns(data["messages"]),
195
+ }
196
+ changed = True
197
+ for sid in list(sessions): # drop rows whose file was pruned/removed
198
+ if sid not in on_disk:
199
+ del sessions[sid]
200
+ changed = True
201
+ if changed:
202
+ _write_index(cwd, sessions)
203
+ items = [{"session_id": sid, **meta} for sid, meta in sessions.items()]
204
+ items.sort(key=lambda it: it.get("updated", 0), reverse=True)
205
+ return items[:limit] if limit else items
206
+
207
+
208
+ def _prune(cwd: str, keep: int = RETAIN) -> None:
209
+ """Retention: keep the newest `keep` sessions, remove older files + index rows."""
210
+ sessions = _load_index(cwd)["sessions"]
211
+ if len(sessions) <= keep:
212
+ return
213
+ ordered = sorted(sessions.items(), key=lambda kv: kv[1].get("updated", 0), reverse=True)
214
+ for sid, _meta in ordered[keep:]:
215
+ try:
216
+ os.remove(_session_path(cwd, sid))
217
+ except OSError:
218
+ pass
219
+ sessions.pop(sid, None)
220
+ _write_index(cwd, sessions)
221
+
222
+
223
+ def save_session(cwd: str, messages: list, meta: dict = None,
224
+ session_id: str = None) -> str:
225
+ """Atomically persist the conversation for `cwd` to its own session file. Mints a
226
+ `session_id` if none is given. Updates the index and prunes old sessions (best-effort).
227
+ Returns the session file path, or '' on failure."""
228
+ try:
229
+ os.makedirs(_dir(cwd), exist_ok=True)
230
+ session_id = session_id or new_session_id()
231
+ updated = time.time()
232
+ path = _session_path(cwd, session_id)
233
+ ok = _atomic_write_json(path, {"cwd": os.path.abspath(cwd), "session_id": session_id,
234
+ "updated": updated, "meta": meta or {},
235
+ "messages": messages})
236
+ if not ok:
237
+ return ""
238
+ _update_index(cwd, session_id, messages, updated)
239
+ _prune(cwd)
240
+ return path
241
+ except OSError:
242
+ return ""
243
+
244
+
245
+ def load_session(cwd: str, session_id: str = None):
246
+ """Return the saved {cwd, updated, meta, messages, session_id} dict. With `session_id`
247
+ that specific session; without one, the most recent (adopting a legacy file first).
248
+ None if there is nothing to resume."""
249
+ if session_id is None:
250
+ items = list_sessions(cwd, limit=1)
251
+ if not items:
252
+ return None
253
+ session_id = items[0]["session_id"]
254
+ return _load_path(_session_path(cwd, session_id))
255
+
256
+
257
+ def _ago(age_s: int) -> str:
258
+ if age_s >= 3600:
259
+ return f"{age_s // 3600}h ago"
260
+ if age_s >= 60:
261
+ return f"{age_s // 60}m ago"
262
+ return "just now"
263
+
264
+
265
+ def describe(item: dict) -> str:
266
+ """One-line label for a `list_sessions` entry (the picker / resume notice):
267
+ `2h ago · 14 turns · "fix the flaky retry test…"`."""
268
+ when = _ago(max(0, int(time.time() - item.get("updated", 0))))
269
+ turns = item.get("turns", 0)
270
+ title = item.get("title") or "(no title)"
271
+ return f'{when} · {turns} turn{"s" * (turns != 1)} · "{title}"'
272
+
273
+
274
+ def session_summary(cwd: str) -> str:
275
+ """One-line description of the most recent session for `cwd` (the `-c` resume notice)."""
276
+ data = load_session(cwd)
277
+ if not data:
278
+ return ""
279
+ users = _turns(data["messages"])
280
+ when = _ago(max(0, int(time.time() - data.get("updated", 0))))
281
+ return f"{users} prior user turn{'s' * (users != 1)}, last active {when}"
chad/skills.py ADDED
@@ -0,0 +1,407 @@
1
+ """Agent Skills support (https://agentskills.io) — discovery, parsing, disclosure, activation.
2
+
3
+ A *skill* is a directory containing a `SKILL.md` file: YAML frontmatter (at minimum
4
+ `name` + `description`) followed by a markdown body of instructions, optionally
5
+ bundling `scripts/`, `references/`, and `assets/`. This module implements the four
6
+ client-side responsibilities from the spec's "adding skills support" guide:
7
+
8
+ 1. Discover — scan project- and user-level skill dirs for `SKILL.md` files.
9
+ 2. Parse — leniently extract frontmatter + body (warn, don't crash, on cosmetic
10
+ issues; skip only when there's no usable description / unparseable YAML).
11
+ 3. Disclose — `catalog_block()` lists name+description+location for the system prompt
12
+ (tier 1: ~50-100 tokens/skill, loaded at session start).
13
+ 4. Activate — `activate(name)` returns the full body wrapped in <skill_content> with a
14
+ bundled-resource listing (tier 2: loaded only when the model asks).
15
+
16
+ Progressive disclosure means the base context stays small: the model sees the catalog
17
+ always, pulls a skill's instructions only when a task matches, and reads bundled
18
+ resources (scripts/refs) on demand via the normal `read` tool.
19
+
20
+ State (the cwd-keyed registry and the set of already-activated skills) lives at module
21
+ level — same pattern as `tools._TODOS` — and is cleared by `reset_session()` when a new
22
+ Agent / `/reset` starts so activations don't bleed across sessions.
23
+ """
24
+
25
+ import os
26
+
27
+ from .diag import log, warn_footer
28
+ from .ignore import IGNORE_DIRS
29
+
30
+ # Bound the discovery walk so a pathological tree (a skills dir nested in a huge repo)
31
+ # can't stall startup. Mirrors the spec's "reasonable bounds" guidance.
32
+ _MAX_DEPTH = 4
33
+ _MAX_DIRS = 2000
34
+ # Directories never worth descending into during a skill scan (shared canonical set).
35
+ _SKIP_DIRS = set(IGNORE_DIRS)
36
+ # Cap the bundled-resource listing returned at activation — a large skill dir shouldn't
37
+ # flood the activation result; the model reads specific files on demand anyway.
38
+ _MAX_RESOURCES = 50
39
+
40
+
41
+ class Skill:
42
+ """One discovered skill: the parsed metadata plus where it lives on disk."""
43
+
44
+ __slots__ = ("name", "description", "location", "base_dir", "body",
45
+ "frontmatter", "warnings")
46
+
47
+ def __init__(self, name, description, location, body, frontmatter, warnings):
48
+ self.name = name
49
+ self.description = description
50
+ self.location = location # absolute path to SKILL.md
51
+ self.base_dir = os.path.dirname(location)
52
+ self.body = body # markdown after the frontmatter
53
+ self.frontmatter = frontmatter # full parsed dict (license/compat/metadata…)
54
+ self.warnings = warnings # lenient-validation diagnostics
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Parsing
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def _split_frontmatter(text: str):
62
+ """Return (frontmatter_yaml, body) for a `---`-delimited file, or (None, text).
63
+
64
+ The opening `---` must be the first line; the closing `---` is the next line that
65
+ is exactly `---`. Everything between is YAML; everything after, trimmed, is the body.
66
+ """
67
+ # Tolerate a leading BOM / blank lines before the first delimiter.
68
+ stripped = text.lstrip("")
69
+ lines = stripped.splitlines(keepends=True)
70
+ if not lines or lines[0].strip() != "---":
71
+ return None, text
72
+ for i in range(1, len(lines)):
73
+ if lines[i].strip() == "---":
74
+ fm = "".join(lines[1:i])
75
+ body = "".join(lines[i + 1:]).strip()
76
+ return fm, body
77
+ return None, text # opened but never closed -> treat as bodyless/invalid
78
+
79
+
80
+ def _quote_colon_values(fm: str) -> str:
81
+ """Best-effort fix for the most common cross-client YAML breakage: an unquoted
82
+ scalar whose value contains a colon-space (`description: Use when: foo`), which a
83
+ strict YAML parser rejects. Wrap such top-level values in double quotes and retry.
84
+ Only touches obvious `key: value` lines; leaves nested/structured YAML alone."""
85
+ out = []
86
+ for ln in fm.splitlines():
87
+ stripped = ln.strip()
88
+ # key at column 0 (no indent), value present, value contains a colon-space,
89
+ # and isn't already quoted / a block scalar / a mapping opener.
90
+ if (ln[:1] not in (" ", "\t", "#", "")
91
+ and ":" in stripped):
92
+ key, _, val = ln.partition(":")
93
+ val_s = val.strip()
94
+ if (val_s and ": " in val_s
95
+ and val_s[0] not in ("'", '"', "|", ">", "[", "{")):
96
+ esc = val_s.replace("\\", "\\\\").replace('"', '\\"')
97
+ out.append(f'{key}: "{esc}"')
98
+ continue
99
+ out.append(ln)
100
+ return "\n".join(out)
101
+
102
+
103
+ def parse_skill_text(text: str, location: str):
104
+ """Parse one SKILL.md's text. Returns (Skill, None) on success or (None, reason) when
105
+ the skill must be skipped (no usable description, or unparseable YAML). Lenient: a bad
106
+ `name` is repaired/derived and recorded as a warning rather than rejected."""
107
+ import yaml
108
+
109
+ fm_text, body = _split_frontmatter(text)
110
+ if fm_text is None:
111
+ return None, "no YAML frontmatter (missing or unterminated `---` block)"
112
+ try:
113
+ meta = yaml.safe_load(fm_text)
114
+ except yaml.YAMLError:
115
+ try:
116
+ meta = yaml.safe_load(_quote_colon_values(fm_text))
117
+ except yaml.YAMLError as e:
118
+ return None, f"unparseable YAML frontmatter: {e}"
119
+ if not isinstance(meta, dict):
120
+ return None, "frontmatter is not a key-value mapping"
121
+
122
+ warnings = []
123
+ desc = meta.get("description")
124
+ desc = desc.strip() if isinstance(desc, str) else ""
125
+ if not desc:
126
+ # A description is essential for disclosure — without it the model can never
127
+ # know when to activate the skill. Skip (the one hard failure besides bad YAML).
128
+ return None, "missing or empty `description`"
129
+ if len(desc) > 1024:
130
+ warnings.append(f"description exceeds 1024 chars ({len(desc)})")
131
+
132
+ dir_name = os.path.basename(os.path.dirname(location))
133
+ name = meta.get("name")
134
+ name = name.strip() if isinstance(name, str) else ""
135
+ if not name:
136
+ name = dir_name
137
+ warnings.append("missing `name`; using parent directory name")
138
+ if name != dir_name:
139
+ warnings.append(f"name {name!r} does not match directory {dir_name!r}")
140
+ if len(name) > 64:
141
+ warnings.append(f"name exceeds 64 chars ({len(name)})")
142
+
143
+ return Skill(name, desc, location, body, meta, warnings), None
144
+
145
+
146
+ def parse_skill_file(location: str):
147
+ """Read and parse a SKILL.md at `location`. Returns (Skill, None) or (None, reason)."""
148
+ try:
149
+ with open(location, "r", errors="replace") as f:
150
+ text = f.read()
151
+ except OSError as e:
152
+ return None, f"cannot read: {e}"
153
+ return parse_skill_text(text, os.path.abspath(location))
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Discovery
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _scope_roots(cwd: str, home: str):
161
+ """The directories to scan, lowest precedence first. Project-level skills override
162
+ user-level ones (the universal convention), so projects come last and win on merge.
163
+ Both the cross-client `.agents/skills` convention and the pragmatic `.claude/skills`
164
+ location are scanned at each level."""
165
+ return [
166
+ ("user", os.path.join(home, ".claude", "skills")),
167
+ ("user", os.path.join(home, ".agents", "skills")),
168
+ ("project", os.path.join(cwd, ".claude", "skills")),
169
+ ("project", os.path.join(cwd, ".agents", "skills")),
170
+ ]
171
+
172
+
173
+ def _find_skill_dirs(root: str):
174
+ """Yield directories under `root` that directly contain a `SKILL.md`, bounded by
175
+ depth and a total-dir cap so a huge tree can't stall the scan."""
176
+ if not os.path.isdir(root):
177
+ return
178
+ seen = 0
179
+ root_depth = root.rstrip(os.sep).count(os.sep)
180
+ for dirpath, dirnames, filenames in os.walk(root):
181
+ seen += 1
182
+ if seen > _MAX_DIRS:
183
+ log.warning("skills: hit %d-dir scan cap under %s; some skills may be missed",
184
+ _MAX_DIRS, root)
185
+ return
186
+ depth = dirpath.rstrip(os.sep).count(os.sep) - root_depth
187
+ if depth >= _MAX_DEPTH:
188
+ dirnames[:] = []
189
+ # prune noise dirs in-place so os.walk doesn't descend into them
190
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
191
+ if "SKILL.md" in filenames:
192
+ yield os.path.join(dirpath, "SKILL.md")
193
+
194
+
195
+ def discover(cwd: str = None, home: str = None):
196
+ """Scan all scopes and return (skills_by_name, order, warnings).
197
+
198
+ `skills_by_name` maps name -> Skill with project-over-user precedence applied;
199
+ `order` is the de-duplicated catalog order (stable: discovery order); `warnings`
200
+ collects per-skill diagnostics plus shadow/collision notes for surfacing in /skills.
201
+ """
202
+ cwd = cwd or os.getcwd()
203
+ home = home or os.path.expanduser("~")
204
+ by_name = {}
205
+ order = []
206
+ warnings = []
207
+ for scope, root in _scope_roots(cwd, home):
208
+ for location in sorted(_find_skill_dirs(root)):
209
+ skill, reason = parse_skill_file(location)
210
+ if skill is None:
211
+ log.warning("skills: skipping %s — %s", location, reason)
212
+ warnings.append(f"{location}: skipped ({reason})")
213
+ continue
214
+ for w in skill.warnings:
215
+ warnings.append(f"{skill.name}: {w}")
216
+ if skill.name in by_name:
217
+ prev = by_name[skill.name]
218
+ # Same name seen again. Project beats user; otherwise first-found wins.
219
+ prev_scope = "project" if _is_project(prev.location, cwd) else "user"
220
+ if scope == "project" and prev_scope == "user":
221
+ warnings.append(
222
+ f"{skill.name}: project skill shadows user skill at {prev.location}")
223
+ by_name[skill.name] = skill # replace in place; keep catalog position
224
+ log.info("skills: %s (project) overrides user copy", skill.name)
225
+ else:
226
+ warnings.append(
227
+ f"{skill.name}: duplicate at {location} shadowed by {prev.location}")
228
+ log.info("skills: %s duplicate at %s ignored", skill.name, location)
229
+ continue
230
+ by_name[skill.name] = skill
231
+ order.append(skill.name)
232
+ return by_name, order, warnings
233
+
234
+
235
+ def _is_project(location: str, cwd: str) -> bool:
236
+ cwd_abs = os.path.abspath(cwd)
237
+ return os.path.abspath(location).startswith(cwd_abs + os.sep)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Registry (cwd-cached) + session activation state
242
+ # ---------------------------------------------------------------------------
243
+
244
+ class _Registry:
245
+ def __init__(self, cwd):
246
+ self.cwd = cwd
247
+ self.by_name, self.order, self.warnings = discover(cwd)
248
+
249
+
250
+ _registry = None # cached _Registry for the current cwd
251
+ _activated = set() # names activated this session (for dedupe)
252
+
253
+
254
+ def get_registry() -> "_Registry":
255
+ """The skill registry for the current cwd, discovered once and cached. Rebuilt
256
+ automatically if the working directory changed since the last call."""
257
+ global _registry
258
+ cwd = os.getcwd()
259
+ if _registry is None or _registry.cwd != cwd:
260
+ _registry = _Registry(cwd)
261
+ if _registry.order:
262
+ log.info("skills: discovered %d skill(s): %s",
263
+ len(_registry.order), ", ".join(_registry.order))
264
+ return _registry
265
+
266
+
267
+ def reset_session():
268
+ """Clear per-session state (activations) and force re-discovery. Called when a new
269
+ Agent / `/reset` starts so a prior session's activated skills don't leak forward."""
270
+ global _registry, _activated
271
+ _registry = None
272
+ _activated = set()
273
+
274
+
275
+ def skill_names():
276
+ """Names of all available skills (used to constrain the activate_skill tool enum)."""
277
+ return list(get_registry().order)
278
+
279
+
280
+ def has_skills() -> bool:
281
+ return bool(get_registry().order)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Disclosure (tier 1) + activation (tier 2)
286
+ # ---------------------------------------------------------------------------
287
+
288
+ _CATALOG_INSTRUCTIONS = (
289
+ "# Skills\n"
290
+ "The following skills provide specialized instructions for specific tasks. Each "
291
+ "lists what it does and when to use it. When a task matches a skill's description, "
292
+ "call the `activate_skill` tool with the skill's `name` to load its full "
293
+ "instructions BEFORE proceeding — do not guess the procedure from the name alone. "
294
+ "A skill's instructions may reference bundled files (scripts/, references/, "
295
+ "assets/); read those on demand with the `read` tool, resolving relative paths "
296
+ "against the skill directory reported when you activate it.\n"
297
+ )
298
+
299
+
300
+ def catalog_block(cwd: str = None) -> str:
301
+ """The tier-1 skills section for the system prompt: behavioral instructions plus a
302
+ compact <available_skills> catalog (name + description + location). Returns "" when
303
+ no skills are installed, so an empty block never confuses the model."""
304
+ reg = get_registry() if cwd is None else _Registry(cwd)
305
+ if not reg.order:
306
+ return ""
307
+ lines = ["\n\n" + _CATALOG_INSTRUCTIONS, "<available_skills>"]
308
+ for name in reg.order:
309
+ s = reg.by_name[name]
310
+ lines.append(" <skill>")
311
+ lines.append(f" <name>{name}</name>")
312
+ lines.append(f" <description>{_one_line(s.description)}</description>")
313
+ lines.append(f" <location>{s.location}</location>")
314
+ lines.append(" </skill>")
315
+ lines.append("</available_skills>")
316
+ return "\n".join(lines)
317
+
318
+
319
+ def _one_line(text: str) -> str:
320
+ """Collapse whitespace so a multi-line description stays on one catalog line."""
321
+ return " ".join(text.split())
322
+
323
+
324
+ def _list_resources(base_dir: str, skill_md: str):
325
+ """Relative paths of bundled files in the skill dir (excluding SKILL.md itself),
326
+ capped. These are surfaced to the model but NOT read — it loads them on demand."""
327
+ files = []
328
+ truncated = False
329
+ for dirpath, dirnames, filenames in os.walk(base_dir):
330
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
331
+ for fn in sorted(filenames):
332
+ full = os.path.join(dirpath, fn)
333
+ if os.path.abspath(full) == os.path.abspath(skill_md):
334
+ continue
335
+ rel = os.path.relpath(full, base_dir)
336
+ files.append(rel)
337
+ if len(files) >= _MAX_RESOURCES:
338
+ truncated = True
339
+ break
340
+ if truncated:
341
+ break
342
+ return sorted(files), truncated
343
+
344
+
345
+ def activate(name: str) -> str:
346
+ """Tier-2 activation: return the named skill's instructions wrapped in identifying
347
+ tags, with its directory and a bundled-resource listing. Frontmatter is stripped
348
+ (the body is the actionable part). Already-activated skills return a short note
349
+ instead of re-injecting the same instructions."""
350
+ reg = get_registry()
351
+ skill = reg.by_name.get(name)
352
+ if skill is None:
353
+ avail = ", ".join(reg.order) or "none installed"
354
+ return (f"[no skill named {name!r}. Available skills: {avail}. "
355
+ f"Call activate_skill with one of those exact names.]")
356
+ if name in _activated:
357
+ return (f"[skill '{name}' is already active earlier in this conversation — its "
358
+ f"instructions are still in effect; no need to re-activate.]")
359
+ _activated.add(name)
360
+ log.info("skills: activated %s (%s)", name, skill.location)
361
+
362
+ resources, truncated = _list_resources(skill.base_dir, skill.location)
363
+ parts = [f'<skill_content name="{name}">', skill.body, "",
364
+ f"Skill directory: {skill.base_dir}",
365
+ "Relative paths in this skill are relative to the skill directory; pass "
366
+ "absolute paths to tools."]
367
+ if resources:
368
+ parts.append("<skill_resources>")
369
+ parts += [f" <file>{r}</file>" for r in resources]
370
+ if truncated:
371
+ parts.append(f" <note>listing capped at {_MAX_RESOURCES} files; more exist</note>")
372
+ parts.append("</skill_resources>")
373
+ parts.append("</skill_content>")
374
+ return "\n".join(parts)
375
+
376
+
377
+ # Marker that identifies an activated-skill tool result, so context compaction can
378
+ # protect it from truncation/dropping (skill instructions are durable behavioral
379
+ # guidance — silently losing them mid-session degrades the agent with no error).
380
+ SKILL_CONTENT_MARKER = "<skill_content "
381
+
382
+
383
+ def is_skill_message(msg: dict) -> bool:
384
+ """True if a conversation message carries activated skill instructions."""
385
+ return (msg.get("role") == "tool"
386
+ and (msg.get("name") == "activate_skill"
387
+ or SKILL_CONTENT_MARKER in msg.get("content", "")))
388
+
389
+
390
+ def summary_lines():
391
+ """Human-readable lines for the `/skills` command: one row per skill (name, an
392
+ `*active*` marker if already loaded, and a trimmed description), plus a footer of
393
+ any discovery warnings. Empty list message when no skills are installed."""
394
+ reg = get_registry()
395
+ if not reg.order:
396
+ return ["no skills installed. Add a SKILL.md under .agents/skills/ (project) or "
397
+ "~/.agents/skills/ (user). See https://agentskills.io"]
398
+ out = []
399
+ for name in reg.order:
400
+ s = reg.by_name[name]
401
+ desc = _one_line(s.description)
402
+ if len(desc) > 100:
403
+ desc = desc[:97] + "…"
404
+ active = " *active*" if name in _activated else ""
405
+ out.append(f"{name}{active} — {desc}")
406
+ out += warn_footer(reg.warnings)
407
+ return out