bone-agent 1.4.0 → 2.0.1
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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- package/src/utils/web_search.py +0 -173
package/src/core/skills.py
DELETED
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
"""User skill storage and active session skill helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import tempfile
|
|
9
|
-
from dataclasses import dataclass, field
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Callable, Generic, TypeVar
|
|
12
|
-
|
|
13
|
-
import yaml
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
MAX_SKILL_BYTES = 32 * 1024
|
|
19
|
-
SKILL_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
20
|
-
_HEADING_RE = re.compile(r"^#\s+(.+?)\s*$")
|
|
21
|
-
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class SkillSummary:
|
|
26
|
-
name: str
|
|
27
|
-
path: Path
|
|
28
|
-
preview: str
|
|
29
|
-
modified: float
|
|
30
|
-
description: str = ""
|
|
31
|
-
tags: list[str] = field(default_factory=list)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
T = TypeVar("T")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@dataclass
|
|
38
|
-
class SearchCandidate(Generic[T]):
|
|
39
|
-
item: T
|
|
40
|
-
text: str
|
|
41
|
-
compact_text: str
|
|
42
|
-
exact_text: str = ""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@dataclass
|
|
46
|
-
class SearchMatch(Generic[T]):
|
|
47
|
-
item: T
|
|
48
|
-
score: float
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class SkillError(ValueError):
|
|
52
|
-
"""Raised when a skill operation cannot be completed."""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def get_skills_dir() -> Path:
|
|
56
|
-
"""Return the configured skills directory."""
|
|
57
|
-
override = os.environ.get("BONE_SKILLS_DIR")
|
|
58
|
-
if override:
|
|
59
|
-
return Path(override).expanduser().resolve()
|
|
60
|
-
return Path.home() / ".bone" / "skills"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def ensure_skills_dir() -> Path:
|
|
64
|
-
"""Create and return the skills directory."""
|
|
65
|
-
path = get_skills_dir()
|
|
66
|
-
path.mkdir(parents=True, exist_ok=True)
|
|
67
|
-
return path
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def normalize_skill_name(raw: str) -> str:
|
|
71
|
-
"""Normalize a user-provided skill name for filesystem storage."""
|
|
72
|
-
return (raw or "").strip().lower().replace(" ", "_")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def validate_skill_name(raw: str) -> str:
|
|
76
|
-
"""Validate and return a normalized skill name."""
|
|
77
|
-
name = normalize_skill_name(raw)
|
|
78
|
-
if not SKILL_NAME_RE.fullmatch(name):
|
|
79
|
-
raise SkillError(
|
|
80
|
-
"Invalid skill name. Use lowercase letters, numbers, underscores, "
|
|
81
|
-
"or hyphens; start with a letter or number."
|
|
82
|
-
)
|
|
83
|
-
if "/" in name or "\\" in name or name.startswith(".") or ".." in name:
|
|
84
|
-
raise SkillError("Invalid skill name.")
|
|
85
|
-
return name
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def get_skill_path(name: str) -> Path:
|
|
89
|
-
"""Return the safe path for a skill name."""
|
|
90
|
-
valid_name = validate_skill_name(name)
|
|
91
|
-
base = ensure_skills_dir().resolve()
|
|
92
|
-
return base / f"{valid_name}.md"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _check_size(content: str) -> None:
|
|
96
|
-
if len(content.encode("utf-8")) > MAX_SKILL_BYTES:
|
|
97
|
-
raise SkillError(f"Skill is too large. Maximum size is {MAX_SKILL_BYTES} bytes.")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
101
|
-
"""Extract YAML frontmatter and remaining body from content.
|
|
102
|
-
|
|
103
|
-
Returns:
|
|
104
|
-
(metadata_dict, body_text). metadata_dict may be empty.
|
|
105
|
-
|
|
106
|
-
Notes:
|
|
107
|
-
If a frontmatter block is present but invalid, preserve the original content
|
|
108
|
-
as body so callers do not silently discard user-authored metadata.
|
|
109
|
-
"""
|
|
110
|
-
match = _FRONTMATTER_RE.match(content)
|
|
111
|
-
if not match:
|
|
112
|
-
return {}, content
|
|
113
|
-
try:
|
|
114
|
-
meta = yaml.safe_load(match.group(1)) or {}
|
|
115
|
-
except yaml.YAMLError:
|
|
116
|
-
return {}, content
|
|
117
|
-
if not isinstance(meta, dict):
|
|
118
|
-
return {}, content
|
|
119
|
-
body = content[match.end():]
|
|
120
|
-
return meta, body
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _normalize_description(value: object) -> str:
|
|
124
|
-
text = str(value or "").strip()
|
|
125
|
-
return text
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _normalize_tags(value: object) -> list[str]:
|
|
129
|
-
if value is None:
|
|
130
|
-
return []
|
|
131
|
-
if isinstance(value, str):
|
|
132
|
-
candidates = [value]
|
|
133
|
-
elif isinstance(value, (list, tuple, set)):
|
|
134
|
-
candidates = list(value)
|
|
135
|
-
else:
|
|
136
|
-
candidates = [value]
|
|
137
|
-
|
|
138
|
-
tags: list[str] = []
|
|
139
|
-
for candidate in candidates:
|
|
140
|
-
tag = str(candidate or "").strip()
|
|
141
|
-
if tag:
|
|
142
|
-
tags.append(tag)
|
|
143
|
-
return tags
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _render_frontmatter(description: str, tags: list[str]) -> str:
|
|
147
|
-
"""Render YAML frontmatter block for a skill file."""
|
|
148
|
-
if not description and not tags:
|
|
149
|
-
return ""
|
|
150
|
-
meta = {}
|
|
151
|
-
if description:
|
|
152
|
-
meta["description"] = description
|
|
153
|
-
if tags:
|
|
154
|
-
meta["tags"] = tags
|
|
155
|
-
return f"---\n{yaml.dump(meta, default_flow_style=False).strip()}\n---\n"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _needs_metadata(meta: dict) -> bool:
|
|
159
|
-
"""Check if frontmatter is missing description or tags."""
|
|
160
|
-
return not meta.get("description") or not meta.get("tags")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _strip_heading(name: str, content: str) -> str:
|
|
164
|
-
lines = content.splitlines()
|
|
165
|
-
if not lines:
|
|
166
|
-
return ""
|
|
167
|
-
match = _HEADING_RE.match(lines[0])
|
|
168
|
-
if match and normalize_skill_name(match.group(1)) == normalize_skill_name(name):
|
|
169
|
-
return "\n".join(lines[1:]).strip()
|
|
170
|
-
return content.strip()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def format_skill_file(name: str, content: str, *, description: str = "", tags: list[str] | None = None) -> str:
|
|
174
|
-
"""Format a skill as a markdown file with optional frontmatter and title heading."""
|
|
175
|
-
valid_name = validate_skill_name(name)
|
|
176
|
-
body = _strip_heading(valid_name, content)
|
|
177
|
-
if not body:
|
|
178
|
-
raise SkillError("Skill prompt cannot be empty.")
|
|
179
|
-
|
|
180
|
-
frontmatter = _render_frontmatter(description, tags or [])
|
|
181
|
-
formatted = f"{frontmatter}# {valid_name}\n\n{body.strip()}\n"
|
|
182
|
-
_check_size(formatted)
|
|
183
|
-
return formatted
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def read_skill(name: str, strip_heading: bool = True) -> str:
|
|
187
|
-
"""Read a skill body by name.
|
|
188
|
-
|
|
189
|
-
Returns the prompt body without frontmatter or heading (unless strip_heading=False,
|
|
190
|
-
in which case frontmatter is still stripped but heading is kept).
|
|
191
|
-
"""
|
|
192
|
-
path = get_skill_path(name)
|
|
193
|
-
if path.is_symlink():
|
|
194
|
-
raise SkillError("Refusing to read a symlinked skill.")
|
|
195
|
-
if not path.is_file():
|
|
196
|
-
raise SkillError(f"Skill '{validate_skill_name(name)}' not found.")
|
|
197
|
-
content = path.read_text(encoding="utf-8")
|
|
198
|
-
_, body = _parse_frontmatter(content)
|
|
199
|
-
if strip_heading:
|
|
200
|
-
return _strip_heading(name, body)
|
|
201
|
-
return body.strip()
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def write_skill(name: str, content: str, overwrite: bool = False) -> Path:
|
|
205
|
-
"""Create or replace a skill file.
|
|
206
|
-
|
|
207
|
-
If the content contains YAML frontmatter with description and tags, those are
|
|
208
|
-
preserved. Otherwise, metadata is auto-generated from the content via the LLM.
|
|
209
|
-
"""
|
|
210
|
-
valid_name = validate_skill_name(name)
|
|
211
|
-
path = get_skill_path(valid_name)
|
|
212
|
-
if path.exists() and not overwrite:
|
|
213
|
-
raise SkillError(f"Skill '{valid_name}' already exists.")
|
|
214
|
-
|
|
215
|
-
# Parse any existing frontmatter from the content
|
|
216
|
-
body = content
|
|
217
|
-
description = ""
|
|
218
|
-
tags: list[str] = []
|
|
219
|
-
|
|
220
|
-
# Check if the raw content has frontmatter already
|
|
221
|
-
raw_meta, raw_body = _parse_frontmatter(content)
|
|
222
|
-
if raw_meta:
|
|
223
|
-
description = _normalize_description(raw_meta.get("description", ""))
|
|
224
|
-
tags = _normalize_tags(raw_meta.get("tags"))
|
|
225
|
-
body = raw_body
|
|
226
|
-
|
|
227
|
-
# If still missing metadata, try to preserve from existing file
|
|
228
|
-
if _needs_metadata({"description": description, "tags": tags}) and path.is_file():
|
|
229
|
-
existing_content = path.read_text(encoding="utf-8")
|
|
230
|
-
existing_meta, _ = _parse_frontmatter(existing_content)
|
|
231
|
-
if not description and existing_meta.get("description"):
|
|
232
|
-
description = _normalize_description(existing_meta["description"])
|
|
233
|
-
if not tags and existing_meta.get("tags"):
|
|
234
|
-
tags = _normalize_tags(existing_meta.get("tags"))
|
|
235
|
-
|
|
236
|
-
# If still missing, auto-generate
|
|
237
|
-
if _needs_metadata({"description": description, "tags": tags}):
|
|
238
|
-
prompt_body = _strip_heading(valid_name, body)
|
|
239
|
-
if prompt_body:
|
|
240
|
-
from core.metadata import generate_metadata
|
|
241
|
-
generated = generate_metadata(prompt_body, valid_name)
|
|
242
|
-
generated_description = _normalize_description(generated.get("description", ""))
|
|
243
|
-
generated_tags = _normalize_tags(generated.get("tags"))
|
|
244
|
-
if not description:
|
|
245
|
-
description = generated_description
|
|
246
|
-
if not tags:
|
|
247
|
-
tags = generated_tags
|
|
248
|
-
|
|
249
|
-
formatted = format_skill_file(valid_name, body, description=description, tags=tags)
|
|
250
|
-
_atomic_write(path, formatted)
|
|
251
|
-
return path
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def remove_skill(name: str) -> Path:
|
|
255
|
-
"""Remove a skill file."""
|
|
256
|
-
path = get_skill_path(name)
|
|
257
|
-
if not path.is_file():
|
|
258
|
-
raise SkillError(f"Skill '{validate_skill_name(name)}' not found.")
|
|
259
|
-
if path.is_symlink():
|
|
260
|
-
raise SkillError("Refusing to remove a symlinked skill.")
|
|
261
|
-
path.unlink()
|
|
262
|
-
return path
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def list_skills(query: str | None = None) -> list[SkillSummary]:
|
|
266
|
-
"""List stored skills, optionally filtering by name/body preview."""
|
|
267
|
-
return [match.item for match in search_skill_matches(query=query)]
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def iter_skill_summaries() -> list[SkillSummary]:
|
|
271
|
-
"""Return all valid stored skill summaries."""
|
|
272
|
-
base = ensure_skills_dir()
|
|
273
|
-
summaries: list[SkillSummary] = []
|
|
274
|
-
|
|
275
|
-
for path in sorted(base.glob("*.md")):
|
|
276
|
-
if not path.is_file() or path.is_symlink():
|
|
277
|
-
continue
|
|
278
|
-
try:
|
|
279
|
-
name = validate_skill_name(path.stem)
|
|
280
|
-
raw = path.read_text(encoding="utf-8")
|
|
281
|
-
meta, body_text = _parse_frontmatter(raw)
|
|
282
|
-
heading_stripped = _strip_heading(name, body_text)
|
|
283
|
-
except SkillError:
|
|
284
|
-
continue
|
|
285
|
-
|
|
286
|
-
summaries.append(
|
|
287
|
-
SkillSummary(
|
|
288
|
-
name=name,
|
|
289
|
-
path=path,
|
|
290
|
-
preview=_preview(heading_stripped),
|
|
291
|
-
modified=path.stat().st_mtime,
|
|
292
|
-
description=_normalize_description(meta.get("description", "")),
|
|
293
|
-
tags=_normalize_tags(meta.get("tags")),
|
|
294
|
-
)
|
|
295
|
-
)
|
|
296
|
-
return summaries
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def search_candidates(
|
|
300
|
-
query: str,
|
|
301
|
-
candidates: list[SearchCandidate[T]],
|
|
302
|
-
*,
|
|
303
|
-
max_results: int = 5,
|
|
304
|
-
item_key: Callable[[T], str] | None = None,
|
|
305
|
-
) -> list[SearchMatch[T]]:
|
|
306
|
-
"""Score and return matching candidates in descending relevance order."""
|
|
307
|
-
query_text = (query or "").strip().lower()
|
|
308
|
-
if not query_text:
|
|
309
|
-
matches = [SearchMatch(item=candidate.item, score=0.0) for candidate in candidates]
|
|
310
|
-
if item_key is not None:
|
|
311
|
-
matches.sort(key=lambda match: item_key(match.item))
|
|
312
|
-
return matches[:max_results]
|
|
313
|
-
|
|
314
|
-
query_compact = _compact_match_text(query_text)
|
|
315
|
-
query_terms = [term for term in query_text.split() if term]
|
|
316
|
-
scored: list[SearchMatch[T]] = []
|
|
317
|
-
|
|
318
|
-
for candidate in candidates:
|
|
319
|
-
text = candidate.text.lower()
|
|
320
|
-
compact_text = candidate.compact_text or _compact_match_text(text)
|
|
321
|
-
exact_text = (candidate.exact_text or "").lower()
|
|
322
|
-
score = 0.0
|
|
323
|
-
|
|
324
|
-
if exact_text and query_text == exact_text:
|
|
325
|
-
score += 120.0
|
|
326
|
-
if exact_text and query_text in exact_text:
|
|
327
|
-
score += 60.0
|
|
328
|
-
if query_text in text:
|
|
329
|
-
score += 40.0
|
|
330
|
-
if query_compact and query_compact in compact_text:
|
|
331
|
-
score += 25.0
|
|
332
|
-
|
|
333
|
-
for term in query_terms:
|
|
334
|
-
if exact_text and term in exact_text:
|
|
335
|
-
score += 15.0
|
|
336
|
-
if term in text:
|
|
337
|
-
score += 10.0
|
|
338
|
-
|
|
339
|
-
if score > 0:
|
|
340
|
-
scored.append(SearchMatch(item=candidate.item, score=score))
|
|
341
|
-
|
|
342
|
-
scored.sort(
|
|
343
|
-
key=lambda match: (
|
|
344
|
-
-match.score,
|
|
345
|
-
item_key(match.item) if item_key is not None else "",
|
|
346
|
-
)
|
|
347
|
-
)
|
|
348
|
-
return scored[:max_results]
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def search_skill_matches(query: str | None = None, max_results: int = 20) -> list[SearchMatch[SkillSummary]]:
|
|
352
|
-
"""Return scored skill matches for discovery surfaces."""
|
|
353
|
-
skills = iter_skill_summaries()
|
|
354
|
-
candidates = [
|
|
355
|
-
SearchCandidate(
|
|
356
|
-
item=skill,
|
|
357
|
-
text=" ".join(
|
|
358
|
-
part
|
|
359
|
-
for part in [
|
|
360
|
-
skill.name,
|
|
361
|
-
skill.description,
|
|
362
|
-
skill.preview,
|
|
363
|
-
" ".join(skill.tags),
|
|
364
|
-
]
|
|
365
|
-
if part
|
|
366
|
-
),
|
|
367
|
-
compact_text=_compact_match_text(
|
|
368
|
-
" ".join(
|
|
369
|
-
part
|
|
370
|
-
for part in [skill.name, skill.description, " ".join(skill.tags)]
|
|
371
|
-
if part
|
|
372
|
-
)
|
|
373
|
-
),
|
|
374
|
-
exact_text=skill.name,
|
|
375
|
-
)
|
|
376
|
-
for skill in skills
|
|
377
|
-
]
|
|
378
|
-
return search_candidates(
|
|
379
|
-
query or "",
|
|
380
|
-
candidates,
|
|
381
|
-
max_results=max_results,
|
|
382
|
-
item_key=lambda skill: skill.name,
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def activate_skill(chat_manager, name: str, content: str | None = None, reload: bool = False) -> int:
|
|
387
|
-
"""Activate a skill in session state and refresh the system prompt."""
|
|
388
|
-
valid_name = validate_skill_name(name)
|
|
389
|
-
body = (content if content is not None else read_skill(valid_name)).strip()
|
|
390
|
-
if not body:
|
|
391
|
-
raise SkillError("Skill prompt cannot be empty.")
|
|
392
|
-
|
|
393
|
-
loaded_skills = getattr(chat_manager, "loaded_skills", None)
|
|
394
|
-
if loaded_skills is None:
|
|
395
|
-
loaded_skills = set()
|
|
396
|
-
setattr(chat_manager, "loaded_skills", loaded_skills)
|
|
397
|
-
if valid_name in loaded_skills and not reload:
|
|
398
|
-
raise SkillError(f"Skill '{valid_name}' is already active in this chat.")
|
|
399
|
-
|
|
400
|
-
loaded_skills.add(valid_name)
|
|
401
|
-
if hasattr(chat_manager, "update_system_prompt"):
|
|
402
|
-
chat_manager.update_system_prompt()
|
|
403
|
-
else:
|
|
404
|
-
chat_manager._update_context_tokens()
|
|
405
|
-
|
|
406
|
-
return chat_manager.token_tracker.estimate_tokens(render_active_skills_section([valid_name]))
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def get_active_skill_contents(skill_names: list[str] | set[str] | tuple[str, ...]) -> list[tuple[str, str]]:
|
|
410
|
-
"""Return validated active skill name/body pairs sorted by skill name."""
|
|
411
|
-
active_skills = []
|
|
412
|
-
for raw_name in sorted({validate_skill_name(name) for name in skill_names}):
|
|
413
|
-
body = read_skill(raw_name)
|
|
414
|
-
if body:
|
|
415
|
-
active_skills.append((raw_name, body))
|
|
416
|
-
return active_skills
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def render_active_skills_section(skill_names: list[str] | set[str] | tuple[str, ...]) -> str:
|
|
420
|
-
"""Render active skills for inclusion in the system prompt."""
|
|
421
|
-
try:
|
|
422
|
-
active_skills = get_active_skill_contents(skill_names)
|
|
423
|
-
except SkillError:
|
|
424
|
-
active_skills = []
|
|
425
|
-
if not active_skills:
|
|
426
|
-
return ""
|
|
427
|
-
|
|
428
|
-
sections = ["## Active skills", "Apply these active skill instructions in addition to the base prompt."]
|
|
429
|
-
for name, body in active_skills:
|
|
430
|
-
sections.append(f"### {name}\n{body}")
|
|
431
|
-
return "\n\n".join(sections)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def _preview(content: str, max_chars: int = 90) -> str:
|
|
435
|
-
text = " ".join(content.split())
|
|
436
|
-
if len(text) <= max_chars:
|
|
437
|
-
return text
|
|
438
|
-
return text[: max_chars - 3].rstrip() + "..."
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
def _compact_match_text(text: str) -> str:
|
|
442
|
-
return re.sub(r"[^a-z0-9]+", "", (text or "").lower())
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def _atomic_write(path: Path, content: str) -> None:
|
|
446
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
-
fd, tmp_name = tempfile.mkstemp(
|
|
448
|
-
prefix=f".{path.stem}.",
|
|
449
|
-
suffix=".tmp",
|
|
450
|
-
dir=str(path.parent),
|
|
451
|
-
text=True,
|
|
452
|
-
)
|
|
453
|
-
tmp_path = Path(tmp_name)
|
|
454
|
-
try:
|
|
455
|
-
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
456
|
-
handle.write(content)
|
|
457
|
-
tmp_path.replace(path)
|
|
458
|
-
except Exception:
|
|
459
|
-
try:
|
|
460
|
-
tmp_path.unlink()
|
|
461
|
-
except OSError:
|
|
462
|
-
pass
|
|
463
|
-
raise
|