Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
646 lines
29 KiB
Python
646 lines
29 KiB
Python
"""Custom skills for the agent.
|
||
|
||
A *skill* is a named, reusable instruction block the user can add and toggle on.
|
||
Enabled skills are injected into the agent's system prompt so the agent
|
||
follows them (e.g. "luôn viết test", "tuân thủ coding style của team").
|
||
|
||
Stored as one JSON file per skill under ``~/.cowork_local/skills/``::
|
||
|
||
{"name": str, "description": str, "instructions": str, "enabled": bool}
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import asdict, dataclass
|
||
from pathlib import Path
|
||
from typing import List
|
||
|
||
from ..config import CONFIG_DIR
|
||
|
||
SKILLS_DIR = CONFIG_DIR / "skills"
|
||
|
||
# Bundled, always-on default skills live here (none ship by default — drop a
|
||
# ``.skill`` file in here to add one). They are loaded straight from the
|
||
# package by builtin_skills() — never copied into the user's editable skills
|
||
# folder — so they always apply to the agent but never appear in (and cannot
|
||
# be edited / disabled / deleted from) the Skills manager.
|
||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "skill_templates"
|
||
|
||
# Bundled BUILT-IN skill LIBRARY (ships with the app). Unlike TEMPLATES_DIR's
|
||
# always-on/hidden skills, these are SEEDED once into the user's editable skills
|
||
# folder on first run — so they appear in the Skill Manager (visible, toggleable,
|
||
# exportable) like any other skill, but arrive out-of-the-box. See
|
||
# seed_library_skills(); a user-deleted one is not re-seeded (tracked in config).
|
||
LIBRARY_DIR = Path(__file__).resolve().parent.parent / "skill_library"
|
||
|
||
|
||
@dataclass
|
||
class Skill:
|
||
"""Một Skill: tên, mô tả, phần chỉ dẫn chèn vào prompt, và cờ bật/tắt."""
|
||
name: str
|
||
description: str = ""
|
||
instructions: str = ""
|
||
enabled: bool = True
|
||
|
||
@property
|
||
def slug(self) -> str:
|
||
"""Định danh an toàn cho tên file, suy từ tên skill."""
|
||
keep = "-_"
|
||
s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower())
|
||
return "-".join(filter(None, s.split("-"))) or "skill"
|
||
|
||
|
||
def skills_dir() -> Path:
|
||
"""Thư mục chứa skill của người dùng."""
|
||
return SKILLS_DIR
|
||
|
||
|
||
def _decode_best_effort(raw: bytes) -> str:
|
||
"""Decode skill-file bytes trying several encodings, so a file saved as
|
||
UTF-16 or Windows-1252 (common from Notepad/Word) imports as real text
|
||
instead of ``�`` replacement boxes (the "font error" users hit). A BOM,
|
||
if present, is consumed by the matching codec."""
|
||
import codecs
|
||
|
||
# UTF-16 ONLY when a BOM says so — without a BOM, Python's utf-16 codec
|
||
# happily "decodes" any even-length bytes into CJK garbage, which would
|
||
# shadow the legitimate encodings tried after it.
|
||
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
||
try:
|
||
return raw.decode("utf-16")
|
||
except UnicodeDecodeError:
|
||
pass
|
||
try:
|
||
return raw.decode("utf-8-sig") # plain UTF-8 too; strips a BOM if present
|
||
except UnicodeDecodeError:
|
||
pass
|
||
# UTF-16 saved WITHOUT a BOM (some editors do): text files never contain
|
||
# NUL bytes otherwise, so a large share of NULs is a reliable tell — and
|
||
# their position (odd/even offsets) says which endianness.
|
||
if raw and raw.count(0) > len(raw) // 4:
|
||
nul_even = raw[::2].count(0)
|
||
nul_odd = raw[1::2].count(0)
|
||
try:
|
||
return raw.decode("utf-16-be" if nul_even > nul_odd else "utf-16-le")
|
||
except UnicodeDecodeError:
|
||
pass
|
||
# Last resort: cp1252 covers most Western text; latin-1 never raises.
|
||
try:
|
||
return raw.decode("cp1252")
|
||
except UnicodeDecodeError:
|
||
return raw.decode("latin-1", errors="replace")
|
||
|
||
|
||
def _parse_frontmatter(text: str):
|
||
"""Split a leading ``---`` YAML frontmatter block (as used by Claude's
|
||
Agent Skills ``SKILL.md``) into a ``{key: value}`` dict + the body after
|
||
it. Only simple ``key: value`` scalars are read (enough for name/
|
||
description); returns ``({}, text)`` when there's no valid block."""
|
||
stripped = text.lstrip("\r\n ")
|
||
if not stripped.startswith("---"):
|
||
return {}, text
|
||
lines = stripped.splitlines()
|
||
meta, body_start = {}, None
|
||
for i in range(1, len(lines)):
|
||
if lines[i].strip() == "---":
|
||
body_start = i + 1
|
||
break
|
||
if ":" in lines[i] and not lines[i].lstrip().startswith("#"):
|
||
k, _, v = lines[i].partition(":")
|
||
meta[k.strip().lower()] = v.strip().strip("\"'")
|
||
if body_start is None:
|
||
return {}, text # no closing --- → not real frontmatter
|
||
return meta, "\n".join(lines[body_start:]).strip()
|
||
|
||
|
||
def _skill_from_text(text: str, suffix: str, stem: str, folder: str = "") -> Skill:
|
||
"""Parse decoded skill text into a Skill. ``suffix``/``stem``/``folder``
|
||
describe the source (a real file or a zip member) so the name can fall
|
||
back sensibly: explicit name > first heading > containing folder > stem."""
|
||
if suffix.lower() == ".json" or text.lstrip().startswith("{"):
|
||
try:
|
||
data = json.loads(text)
|
||
except json.JSONDecodeError:
|
||
data = None # fall through to text parsing
|
||
# Only a JSON OBJECT is a skill payload — a stray array/scalar file
|
||
# must not crash skill listing (fall back to plain-text parsing).
|
||
if isinstance(data, dict):
|
||
return Skill(
|
||
name=data.get("name", stem),
|
||
description=data.get("description", ""),
|
||
instructions=data.get("instructions") or data.get("content", ""),
|
||
# Skills are OFF by default — the user opts in (ticks) to use one.
|
||
enabled=bool(data.get("enabled", False)),
|
||
)
|
||
meta, body = _parse_frontmatter(text)
|
||
name = meta.get("name") or ""
|
||
description = meta.get("description", "")
|
||
if not meta:
|
||
body = text.strip()
|
||
for line in text.splitlines():
|
||
if line.strip().startswith("#"):
|
||
name = line.lstrip("#").strip()
|
||
break
|
||
# No explicit name from frontmatter/heading: a Claude SKILL.md is usually
|
||
# in a folder named after the skill, so prefer that over the generic stem.
|
||
if not name:
|
||
name = (folder if stem.lower() == "skill" else "") or stem or folder
|
||
return Skill(name=name, description=description, instructions=body, enabled=False)
|
||
|
||
|
||
def _load_skill_file(path: Path) -> Skill | None:
|
||
"""Load a skill from a file. Supports JSON (.json or JSON-bodied .skill),
|
||
and Markdown/plain text (.skill/.md/.txt/.yaml) — including Claude Agent
|
||
Skill ``SKILL.md`` files, whose ``---`` frontmatter supplies the name and
|
||
description; otherwise the first heading is the name. Encoding is
|
||
auto-detected (see ``_decode_best_effort``)."""
|
||
try:
|
||
raw = path.read_bytes()
|
||
except OSError:
|
||
return None
|
||
if raw[:4] == b"PK\x03\x04":
|
||
# A zip-formatted .skill dropped straight into the skills folder —
|
||
# same handling as importing the package via the Import button.
|
||
return _load_skill_from_zip(path)
|
||
return _skill_from_text(_decode_best_effort(raw), path.suffix, path.stem, path.parent.name)
|
||
|
||
|
||
def builtin_skills() -> List[Skill]:
|
||
"""Bundled default skills that always run but stay hidden from the manager.
|
||
|
||
Loaded directly from the packaged ``skill_templates`` folder and forced
|
||
``enabled`` — the user can neither see, edit, disable nor delete them. Their
|
||
instructions are always injected into the agent by active_skills_text()."""
|
||
out: List[Skill] = []
|
||
if not TEMPLATES_DIR.exists():
|
||
return out
|
||
for path in sorted(TEMPLATES_DIR.glob("*.skill")):
|
||
skill = _load_skill_file(path)
|
||
if skill is not None:
|
||
skill.enabled = True # built-ins are always on
|
||
out.append(skill)
|
||
return out
|
||
|
||
|
||
def _builtin_slugs() -> set[str]:
|
||
"""Tập slug của các skill dựng sẵn — dùng để không gieo trùng và không cho sửa
|
||
chúng như skill thường.
|
||
"""
|
||
return {s.slug for s in builtin_skills()}
|
||
|
||
|
||
def library_skills() -> List[Skill]:
|
||
"""The bundled built-in skill LIBRARY (from the packaged ``skill_library``
|
||
folder). These are seeded into the user's editable skills on first run — see
|
||
seed_library_skills() — so they show up in the Skill Manager. Loaded here
|
||
disabled (opt-in), as normal skills."""
|
||
out: List[Skill] = []
|
||
if not LIBRARY_DIR.exists():
|
||
return out
|
||
for path in sorted(LIBRARY_DIR.glob("*.skill")) + sorted(LIBRARY_DIR.glob("*.md")):
|
||
skill = _load_skill_file(path)
|
||
if skill is not None and skill.name:
|
||
skill.enabled = False
|
||
out.append(skill)
|
||
return out
|
||
|
||
|
||
def _lib_hash(skill: Skill) -> str:
|
||
"""Short content fingerprint of a bundled skill (name + instructions) — used
|
||
to detect when the SHIPPED version changed so the seeded copy can be refreshed."""
|
||
import hashlib
|
||
raw = (skill.name + "\x00" + skill.instructions).encode("utf-8")
|
||
return hashlib.sha1(raw).hexdigest()[:10]
|
||
|
||
|
||
def seed_library_skills(seeded_tags=None, directory: Path | None = None) -> List[str]:
|
||
"""Seed/refresh the bundled library skills in the user's Skill Manager, and
|
||
return the FULL list of version tags (``slug@hash``) to persist.
|
||
|
||
Per skill, content-versioned so updates ship without clobbering user edits:
|
||
* brand-new skill → seeded;
|
||
* SHIPPED content changed (tag not recorded) AND the file is still present →
|
||
(over)written, delivering the update;
|
||
* unchanged (tag already recorded) → left as-is (user edits preserved);
|
||
* user deleted it (a prior tag for this slug was recorded but the file is
|
||
gone) → respected, not recreated.
|
||
"""
|
||
directory = directory or SKILLS_DIR
|
||
already = set(seeded_tags or [])
|
||
tags: List[str] = []
|
||
for skill in library_skills():
|
||
slug = skill.slug
|
||
tag = f"{slug}@{_lib_hash(skill)}"
|
||
tags.append(tag) # record the current shipped version
|
||
if tag in already:
|
||
continue # this exact version already handled
|
||
exists = (directory / f"{slug}.json").exists() or (directory / f"{slug}.skill").exists()
|
||
prior = any(t.split("@", 1)[0] == slug for t in already)
|
||
if prior and not exists:
|
||
continue # previously seeded then deleted → respect it
|
||
save_skill(skill, directory) # first seed OR shipped-content upgrade
|
||
return tags
|
||
|
||
|
||
def export_skill_md(skill: Skill, out_path) -> Path:
|
||
"""Write a skill to a Markdown ``.md`` file with YAML frontmatter (name +
|
||
description) followed by the instructions body — the same shape a Claude
|
||
Agent ``SKILL.md`` uses, so it round-trips back through import_skill_file."""
|
||
p = Path(out_path)
|
||
if p.suffix.lower() != ".md":
|
||
p = p.with_suffix(".md")
|
||
desc = (skill.description or "").replace("\n", " ").strip()
|
||
lines = ["---", f"name: {skill.name}"]
|
||
if desc:
|
||
lines.append(f"description: {desc}")
|
||
lines += ["---", "", f"# {skill.name}", ""]
|
||
if desc:
|
||
lines += [f"> {desc}", ""]
|
||
lines.append((skill.instructions or "").strip())
|
||
p.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||
return p
|
||
|
||
|
||
def list_skills(directory: Path | None = None) -> List[Skill]:
|
||
"""User-managed skills shown in the Skills manager.
|
||
|
||
Built-in default skills are deliberately excluded so they never appear in —
|
||
nor can be toggled from — any management UI; see builtin_skills()."""
|
||
directory = directory or SKILLS_DIR
|
||
if not directory.exists():
|
||
return []
|
||
builtin = _builtin_slugs()
|
||
out: List[Skill] = []
|
||
seen: set[str] = set()
|
||
for path in sorted(directory.glob("*.json")) + sorted(directory.glob("*.skill")):
|
||
if path.name in seen:
|
||
continue
|
||
seen.add(path.name)
|
||
skill = _load_skill_file(path)
|
||
if skill is not None and skill.slug not in builtin:
|
||
out.append(skill)
|
||
return out
|
||
|
||
|
||
def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "") -> Path:
|
||
"""Ghi một skill xuống đĩa.
|
||
|
||
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên skill, không xoá sẽ để
|
||
lại một bản sao dưới tên cũ.
|
||
"""
|
||
directory = directory or SKILLS_DIR
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
if old_name and old_name != skill.name:
|
||
delete_skill(old_name, directory)
|
||
path = directory / f"{skill.slug}.json"
|
||
path.write_text(json.dumps(asdict(skill), ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def delete_skill(name: str, directory: Path | None = None) -> None:
|
||
"""Xoá file skill theo tên; không có thì bỏ qua."""
|
||
directory = directory or SKILLS_DIR
|
||
path = directory / f"{Skill(name=name).slug}.json"
|
||
if path.exists():
|
||
try:
|
||
path.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _load_skill_from_zip(path: Path) -> "Skill | None":
|
||
"""Import a Claude Agent Skill distributed as a .zip: find the SKILL.md
|
||
(or any .md/.skill/.json/.txt) inside and parse it, reading the member
|
||
bytes directly (no temp extraction). The name falls back to the member's
|
||
own folder inside the archive, else the .zip filename."""
|
||
import zipfile
|
||
|
||
def _rank(n: str) -> int:
|
||
"""Thứ tự ưu tiên khi gói zip có nhiều file Markdown: ``skill.md`` trước, rồi
|
||
tới file ở gốc gói, cuối cùng mới tới file nằm sâu.
|
||
"""
|
||
low = n.lower()
|
||
if low.endswith("skill.md"):
|
||
return 0
|
||
if low.endswith((".md", ".skill")):
|
||
return 1
|
||
if low.endswith(".json"):
|
||
return 2
|
||
return 3
|
||
|
||
try:
|
||
with zipfile.ZipFile(path) as zf:
|
||
candidates = sorted(
|
||
(n for n in zf.namelist()
|
||
if not n.endswith("/") and n.lower().endswith((".md", ".skill", ".json", ".txt"))),
|
||
key=_rank)
|
||
if not candidates:
|
||
return None
|
||
member = candidates[0]
|
||
text = _decode_best_effort(zf.read(member))
|
||
except (zipfile.BadZipFile, OSError, KeyError):
|
||
return None
|
||
mp = Path(member)
|
||
folder = mp.parent.name or path.stem
|
||
return _skill_from_text(text, mp.suffix, mp.stem, folder)
|
||
|
||
|
||
def _is_zip_file(path: Path) -> bool:
|
||
"""A Claude Agent Skill package is a ZIP whatever its extension — official
|
||
exports ship as ``<name>.skill`` which IS a zip archive. Sniff the magic
|
||
bytes instead of trusting the suffix, or the archive gets decoded as text
|
||
and the skill imports as binary mojibake ("lỗi font")."""
|
||
try:
|
||
with path.open("rb") as f:
|
||
return f.read(4) == b"PK\x03\x04"
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def import_skill_file(path, directory: Path | None = None) -> Skill:
|
||
"""Import an external skill and save it. Supports single files (.json,
|
||
.skill, .md, .txt, .yaml — including Claude ``SKILL.md`` with frontmatter)
|
||
and Claude Agent Skill packages (.zip, or a zip-formatted .skill)."""
|
||
directory = directory or SKILLS_DIR
|
||
p = Path(path)
|
||
if p.suffix.lower() == ".zip" or _is_zip_file(p):
|
||
skill = _load_skill_from_zip(p)
|
||
else:
|
||
skill = _load_skill_file(p)
|
||
if skill is None or not skill.name:
|
||
raise ValueError(f"Could not read a skill from: {path}")
|
||
save_skill(skill, directory)
|
||
return skill
|
||
|
||
|
||
def prune_seeded_builtins(directory: Path | None = None) -> None:
|
||
"""Remove previously-seeded copies of built-in skills from the user's folder.
|
||
|
||
Earlier versions copied the bundled ``.skill`` templates (e.g. 'HTML Document
|
||
Builder') into the user's skills dir on first run. Built-ins are now loaded
|
||
straight from the package (see builtin_skills()) and must stay hidden from the
|
||
Skills manager, so any leftover seeded copy is deleted here on startup. Only
|
||
``.skill`` files are touched — user-created / imported skills are always saved
|
||
as ``.json`` — so a user's own skills are never removed."""
|
||
directory = directory or SKILLS_DIR
|
||
if not directory.exists():
|
||
return
|
||
builtin = _builtin_slugs()
|
||
try:
|
||
for path in directory.glob("*.skill"):
|
||
skill = _load_skill_file(path)
|
||
if skill is not None and skill.slug in builtin:
|
||
path.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def active_skills_text(directory: Path | None = None) -> str:
|
||
"""Combine built-in (always-on) + enabled user skills into one block (or '').
|
||
|
||
Built-ins come first so any bundled default capabilities always apply,
|
||
even though they never show up in the Skills manager."""
|
||
directory = directory or SKILLS_DIR
|
||
skills = builtin_skills() + [s for s in list_skills(directory) if s.enabled]
|
||
parts = [
|
||
f"## Skill: {s.name}\n{s.instructions.strip()}"
|
||
for s in skills
|
||
if s.instructions.strip()
|
||
]
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
def generate_skill(provider, prompt: str, cancel=None) -> "Skill":
|
||
"""Auto-generate a FULL skill (name + description + instructions) from a free-text
|
||
description. Asks the model for a JSON object; if that doesn't parse, falls back
|
||
to the instructions-only generator plus a name/description derived from the
|
||
prompt. Raises ValueError only when nothing usable could be produced, so the
|
||
caller can show a friendly message."""
|
||
prompt = (prompt or "").strip()
|
||
if not prompt:
|
||
raise ValueError("empty description")
|
||
messages = [
|
||
{"role": "system", "content":
|
||
"You design reusable agent skills. From the user's description, output a SINGLE JSON "
|
||
"object with EXACTLY these keys: \"name\" (a short Title Case name), \"description\" "
|
||
"(one sentence), \"instructions\" (clear imperative guidance — a few short bullet "
|
||
"points telling a coding/assistant agent how to behave whenever this skill is "
|
||
"active). Reply with ONLY the JSON object — no code fences, no preamble."},
|
||
{"role": "user", "content": prompt},
|
||
]
|
||
content = ""
|
||
try:
|
||
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
||
content = (a.get("content") or "").strip()
|
||
except Exception: # noqa: BLE001 - generation must never raise into the UI thread
|
||
content = ""
|
||
|
||
name = description = instructions = ""
|
||
start, end = content.find("{"), content.rfind("}")
|
||
if 0 <= start < end:
|
||
try:
|
||
data = json.loads(content[start:end + 1])
|
||
name = str(data.get("name", "")).strip()
|
||
description = str(data.get("description", "")).strip()
|
||
instructions = str(data.get("instructions") or data.get("content", "")).strip()
|
||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||
pass
|
||
if not instructions:
|
||
# Model didn't return clean JSON — reuse the instructions-only generator.
|
||
instructions = generate_skill_instructions(provider, prompt, "", cancel)
|
||
if not instructions:
|
||
raise ValueError("generation produced no instructions")
|
||
if not name:
|
||
name = (prompt[:40].strip().rstrip(".") or "New Skill")
|
||
if not description:
|
||
description = prompt[:80].strip()
|
||
return Skill(name=name, description=description, instructions=instructions, enabled=False)
|
||
|
||
|
||
def generate_skill_instructions(provider, description: str = "", name: str = "", cancel=None) -> str:
|
||
"""Best-effort: turn a short description into the INSTRUCTIONS body of a skill.
|
||
Returns '' on any error (so the dialog never breaks)."""
|
||
description = (description or "").strip()
|
||
if not description and not name:
|
||
return ""
|
||
user = (f"Skill name: {name}\n" if name else "") + f"Short description: {description}"
|
||
messages = [
|
||
{"role": "system", "content":
|
||
"You write the INSTRUCTIONS body of a reusable agent skill. Given a short description, "
|
||
"produce clear, imperative guidance (a few short bullet points or paragraphs) telling a "
|
||
"coding assistant how to behave whenever this skill is active. Reply with ONLY the "
|
||
"instructions text — no preamble, no title."},
|
||
{"role": "user", "content": user},
|
||
]
|
||
try:
|
||
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
||
except Exception: # noqa: BLE001 - generation must never break the dialog
|
||
return ""
|
||
return (a.get("content") or "").strip()
|
||
|
||
|
||
def generate_skill_from_template(provider, file_path, cancel=None) -> "Skill":
|
||
"""Analyze a pptx/xlsx TEMPLATE file's structure/styling (via
|
||
``doc_style_extract``) plus its own text content (placeholders, sample
|
||
data, notes — via ``doc_extract``) and draft a skill whose instructions
|
||
tell the agent how to replicate this exact template — layout, fonts,
|
||
colors, formatting — the next time it generates a similar file. Raises
|
||
ValueError when the file type is unsupported or nothing usable could be
|
||
produced, so the caller can show a friendly message (same contract as
|
||
:func:`generate_skill`)."""
|
||
from .doc_extract import extract_text
|
||
from .doc_style_extract import extract_structure
|
||
|
||
path = Path(file_path)
|
||
suffix = path.suffix.lower().lstrip(".")
|
||
if suffix not in ("pptx", "xlsx", "xlsm"):
|
||
raise ValueError(f"unsupported template type: .{suffix}")
|
||
structure = extract_structure(path)
|
||
text, _note = extract_text(path)
|
||
if not structure and not text:
|
||
raise ValueError("could not read the template's structure or content")
|
||
|
||
parts = [f"Template file: {path.name} (.{suffix})"]
|
||
if structure:
|
||
parts.append("Structure/styling:\n" + structure[:6000])
|
||
if text:
|
||
parts.append("Text content (placeholders, sample data, notes):\n" + text[:3000])
|
||
context = "\n\n".join(parts)
|
||
|
||
messages = [
|
||
{"role": "system", "content":
|
||
"You design reusable agent skills. Below is a structural/styling analysis "
|
||
"of a PowerPoint or Excel TEMPLATE file, plus its own text content. Output a "
|
||
"SINGLE JSON object with EXACTLY these keys: \"name\" (a short Title Case "
|
||
"name based on what the template is for), \"description\" (one sentence), "
|
||
"\"instructions\" (clear, imperative guidance telling a coding agent EXACTLY "
|
||
"how to replicate this template's layout/fonts/colors/formatting/structure "
|
||
"the next time it generates a similar file via python-pptx/openpyxl — be "
|
||
"specific about fonts, sizes, colors, and layout/structure, not generic "
|
||
"advice). Reply with ONLY the JSON object — no code fences, no preamble."},
|
||
{"role": "user", "content": context},
|
||
]
|
||
content = ""
|
||
try:
|
||
a = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
|
||
content = (a.get("content") or "").strip()
|
||
except Exception: # noqa: BLE001 - generation must never raise into the UI thread
|
||
content = ""
|
||
|
||
name = description = instructions = ""
|
||
start, end = content.find("{"), content.rfind("}")
|
||
if 0 <= start < end:
|
||
try:
|
||
data = json.loads(content[start:end + 1])
|
||
name = str(data.get("name", "")).strip()
|
||
description = str(data.get("description", "")).strip()
|
||
instructions = str(data.get("instructions") or data.get("content", "")).strip()
|
||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||
pass
|
||
if not instructions:
|
||
raise ValueError("generation produced no instructions")
|
||
if not name:
|
||
name = path.stem[:40] or "Template Skill"
|
||
if not description:
|
||
description = f"Replicate the layout/styling of {path.name}."
|
||
return Skill(name=name, description=description, instructions=instructions, enabled=False)
|
||
|
||
|
||
def skill_prefix_for(slug: str, directory: Path | None = None) -> str:
|
||
"""Return the ``## Skill: <name>\\n<instructions>`` block for a single skill
|
||
chosen by ``slug`` (matched against user skills AND always-on built-ins), or
|
||
``''`` when the slug is empty, unknown, or the skill has no instructions.
|
||
|
||
Used by Schedule Task to apply a chosen skill to an unattended run — the
|
||
block is prepended to the task prompt, same shape ``active_skills_text`` and
|
||
``parse_skill_command`` produce for the interactive chat."""
|
||
if not slug:
|
||
return ""
|
||
directory = directory or SKILLS_DIR
|
||
low = slug.strip().lower()
|
||
for s in list_skills(directory) + builtin_skills():
|
||
if (s.slug == low or s.name.lower() == low) and s.instructions.strip():
|
||
return f"## Skill: {s.name}\n{s.instructions.strip()}"
|
||
return ""
|
||
|
||
|
||
def parse_skill_command(text: str, directory: Path | None = None):
|
||
"""Parse a ``/skill`` command anywhere in the chat box text (usable from any
|
||
chat box) — not just when it's the first thing typed, so the user can put
|
||
context before and/or after it.
|
||
|
||
Returns ``(prefix, request, info)``:
|
||
* ``prefix`` – skill instructions to prepend to the agent prompt ('' if none)
|
||
* ``request`` – the user's request with the command stripped
|
||
* ``info`` – when not None, answer this inline (no agent turn)
|
||
|
||
Forms: ``/skill`` (list) · ``/skill:<name> <req>`` (one skill) ·
|
||
``/skill <req>`` (all enabled skills). The command may appear at the
|
||
start, middle, or end of the message; the surrounding text is kept and
|
||
joined together as the request."""
|
||
directory = directory or SKILLS_DIR
|
||
import re
|
||
|
||
raw = (text or "").strip()
|
||
# The command may sit mid-sentence, so punctuation right after it must not
|
||
# break recognition ("dùng /skill:fpt-slide-generator, tạo slide…"): match
|
||
# up to a word boundary, then trim punctuation the char-class swallowed.
|
||
m = re.search(r"(?<!\S)/skill(?::([\w\-.]+))?(?=$|[\s.,;:!?)\]}»”’'\"、。」])",
|
||
raw, re.DOTALL)
|
||
if not m:
|
||
return "", text, None
|
||
slug = m.group(1)
|
||
if slug:
|
||
slug = slug.rstrip(".") # "/skill:name." — the dot was sentence punctuation
|
||
rest = (raw[:m.start()] + " " + raw[m.end():]).strip()
|
||
rest = re.sub(r"[ \t]{2,}", " ", rest) # no double space where the command sat
|
||
user_skills = list_skills(directory)
|
||
builtins = builtin_skills()
|
||
builtin_slugs = {s.slug for s in builtins}
|
||
# Built-in skills are always on and hidden from the Skills manager, but the
|
||
# /skill command must still surface them — otherwise a fresh install (no custom
|
||
# skills yet) makes /skill look empty/broken.
|
||
skills = user_skills + builtins
|
||
if slug:
|
||
low = slug.lower()
|
||
match = next((s for s in skills
|
||
if s.slug == low or s.name.lower() == low or s.slug == slug), None)
|
||
if match is None:
|
||
return "", text, (f"Skill `{slug}` not found. "
|
||
"Type `/skill` to see the available skills.")
|
||
if not rest:
|
||
return "", text, (f"Skill **{match.name}** selected — add your request, e.g. "
|
||
f"`/skill:{slug} summarise this file`.")
|
||
return f"## Skill: {match.name}\n{match.instructions.strip()}", rest, None
|
||
if not rest:
|
||
if not skills:
|
||
return "", text, ("No skills found yet. Add one in the Skills manager "
|
||
"(**Skills** button).")
|
||
|
||
def _tag(s: "Skill") -> str:
|
||
if s.slug in builtin_slugs:
|
||
return " _(built-in, always on)_"
|
||
return "" if s.enabled else " _(disabled)_"
|
||
|
||
listing = "\n".join(
|
||
f"- `/skill:{s.slug}` — **{s.name}**"
|
||
+ (f": {s.description}" if s.description else "")
|
||
+ _tag(s)
|
||
for s in skills
|
||
)
|
||
return "", text, ("**Available skills**\n" + listing
|
||
+ "\n\nApply one with `/skill:<name> <your request>`, "
|
||
"or `/skill <your request>` to use all enabled skills.")
|
||
# /skill <request> with no specific name → apply all enabled skills.
|
||
active = active_skills_text(directory)
|
||
if active:
|
||
return active, rest, None
|
||
if len(skills) == 1:
|
||
s = skills[0]
|
||
return f"## Skill: {s.name}\n{s.instructions.strip()}", rest, None
|
||
listing = "\n".join(
|
||
f"- `/skill:{s.slug}` — **{s.name}**" + (f": {s.description}" if s.description else "")
|
||
for s in skills
|
||
)
|
||
return "", text, ("Chưa bật skill nào. Chọn một skill cụ thể:\n" + listing)
|