aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 18:28:47 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 18:28:47 +0200
commit3bbd3b6e95ee06b8d4171196ee4e4cfb6f43ba9e (patch)
tree3cd6012bfd73d22fb8a96a61cba78bb38d2e1d21
parent63786ca2f15c051ca9d77d98d329b64813f1276a (diff)
downloadllamachat-3bbd3b6e95ee06b8d4171196ee4e4cfb6f43ba9e.tar.gz
llamachat-3bbd3b6e95ee06b8d4171196ee4e4cfb6f43ba9e.zip
feat: inject loaded skills into the system prompt and load from text
-rw-r--r--llamachat/ui.py113
-rwxr-xr-xtest_llamachat.py31
2 files changed, 143 insertions, 1 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index c22b3db..f29b39c 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -36,7 +36,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QWidget,
)
-from . import backend, models as models_mod, prompts
+from . import backend, models as models_mod, prompts, skills
from . import providers as providers_mod
from .backend import Attachment, BackendError, SearchConfig
from .config import GLOBAL_PROMPT
@@ -511,6 +511,8 @@ class ChatWindow(QMainWindow):
self.store = store
self.prompts = prompts.PromptStore(cfg.prompts_dir)
self.prompts.ensure_default()
+ self.skills = skills.SkillStore(cfg.skills_dir)
+ self.loaded_skills: list[str] = []
# Which system prompt this conversation uses: a name from the store,
# the NONE sentinel, or CUSTOM with text held in prompt_custom.
@@ -1056,6 +1058,8 @@ class ChatWindow(QMainWindow):
self.assistant_buffer = ""
self.reasoning_buffer = ""
self.searches = []
+ self.loaded_skills = []
+ self._refresh_skill_chips()
self.exact_tokens = 0
self.bubbles.clear()
self.expanded.clear()
@@ -1203,6 +1207,10 @@ class ChatWindow(QMainWindow):
self.show_status("No model selected.", error=True)
return
+ text = self._load_skills_from_text(text)
+ if not text and not self.attachments:
+ return
+
if self.session_id is None:
title = backend.placeholder_title(
text or self.attachments[0].path.name
@@ -1215,6 +1223,8 @@ class ChatWindow(QMainWindow):
prompt_custom=self.prompt_custom,
)
+ self._persist_skills()
+
content = backend.build_user_content(text, self.attachments)
stored = content if isinstance(content, str) else content[0]["text"]
message_id = self.history.add_message(self.session_id, "user", stored)
@@ -1266,9 +1276,89 @@ class ChatWindow(QMainWindow):
parts = [self.system_prompt_text()]
if self.cfg.search_enabled:
parts.append(_date_note())
+ parts.extend(_skill_parts(self.skills, self.loaded_skills))
text = "\n\n".join(p for p in parts if p)
return [{"role": "system", "content": text}] if text else []
+ # -- skills -----------------------------------------------------------
+
+ def _load_skill(self, name: str) -> bool:
+ """Record a skill as loaded; True when it was not already present.
+
+ Persisted to the session in chat mode so reopening the conversation
+ restores it. One-shot mode keeps the set in memory only; it is
+ cleared when the reply finishes.
+ """
+ if not self.cfg.skills_enabled or name in self.loaded_skills:
+ return False
+ if self.skills.load(name) is None:
+ return False
+ self.loaded_skills.append(name)
+ self._persist_skills()
+ self._refresh_skill_chips()
+ return True
+
+ def _unload_skill(self, name: str) -> None:
+ if name not in self.loaded_skills:
+ return
+ self.loaded_skills.remove(name)
+ self._persist_skills()
+ self._refresh_skill_chips()
+
+ def _persist_skills(self) -> None:
+ if (
+ self.cfg.skills_enabled
+ and self.mode == MODE_CHAT
+ and self.session_id is not None
+ ):
+ self.history.set_skills(self.session_id, self.loaded_skills)
+
+ def _load_skills_from_text(self, text: str) -> str:
+ """Load skills named in the draft; returns the text to actually send.
+
+ /name tokens load and are stripped. A skill name appearing as a
+ whole word loads and stays, since it reads naturally either way.
+ """
+ if not self.cfg.skills_enabled:
+ return text
+ known = set(self.skills.names())
+ text, command_names = skills.parse_commands(text, known)
+ for name in command_names:
+ self._load_skill(name)
+ for name in self.skills.match_mentions(text):
+ self._load_skill(name)
+ return text
+
+ def _skills_config(self) -> backend.SkillsConfig | None:
+ """What the stream worker needs to offer the load_skill tool."""
+ if not self.cfg.skills_enabled or not self.skills.names():
+ return None
+ return backend.SkillsConfig(
+ enabled=True, store=self.skills, max_searches=self.cfg.max_searches
+ )
+
+ def _refresh_skill_chips(self) -> None:
+ """Rebuild the loaded-skill chips row; hidden when nothing is loaded.
+
+ The chips bar is built by `_build_ui`, so this is a no-op until then
+ (it is first called from `new_session` during construction).
+ """
+ if not hasattr(self, "skills_bar"):
+ return # still building the window
+ for chip in self.skill_chips.values():
+ chip.deleteLater()
+ self.skill_chips.clear()
+ for name in self.loaded_skills:
+ chip = QPushButton(f"✕ {name}")
+ chip.setFlat(True)
+ chip.setToolTip(f"Loaded skill. Click to unload {name}.")
+ chip.clicked.connect(
+ lambda _checked=False, n=name: self._unload_skill(n)
+ )
+ self.skills_layout.addWidget(chip)
+ self.skill_chips[name] = chip
+ self.skills_bar.setVisible(bool(self.loaded_skills))
+
def update_meter(self) -> None:
"""Refresh the context bar from whatever is currently composed.
@@ -2103,6 +2193,27 @@ def _date_note(now: datetime.datetime | None = None) -> str:
)
+def _skill_parts(store, loaded: list[str]) -> list[str]:
+ """The system-prompt chunks for the loaded skills, in load order."""
+ parts = []
+ for name in loaded:
+ skill = store.load(name)
+ if skill is not None:
+ parts.append(f"[skill: {skill.name}]\n{skill.text}")
+ return parts
+
+
+def _skills_list(raw: str) -> list[str]:
+ """Session-stored skills JSON back to a list, tolerating absence/garbage."""
+ try:
+ parsed = json.loads(raw) if raw else []
+ except ValueError:
+ return []
+ if not isinstance(parsed, list):
+ return []
+ return [name for name in parsed if isinstance(name, str)]
+
+
def _searches(stored: str) -> list[dict]:
"""Decode the stored searches column into a list, tolerating anything."""
if not stored:
diff --git a/test_llamachat.py b/test_llamachat.py
index ebb90ce..85e69a6 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -3160,6 +3160,8 @@ def test_date_note():
class _Win:
cfg = _Cfg()
system_prompt_text = staticmethod(lambda: "Be terse.")
+ skills = None
+ loaded_skills = []
_system_messages = ui.ChatWindow._system_messages
msgs = _Win._system_messages(_Win())
@@ -3684,6 +3686,34 @@ def test_skills_column_migration():
print("ok skills column migration")
+def test_skill_parts():
+ from llamachat import skills
+ from llamachat.ui import _skill_parts, _skills_list
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+
+ parts = _skill_parts(store, ["finance-cli"])
+ assert len(parts) == 1, parts
+ assert "[skill: finance-cli]" in parts[0]
+ assert "finance auth test" in parts[0]
+
+ # A name that no longer exists contributes nothing, and the parts
+ # keep load order.
+ parts2 = _skill_parts(store, ["gone", "finance-cli"])
+ assert parts2 == [
+ "[skill: finance-cli]\n# finance-cli\nRun `finance auth test` first."
+ ]
+
+ assert _skills_list('["a", "b"]') == ["a", "b"]
+ assert _skills_list("") == []
+ assert _skills_list("not json") == []
+ assert _skills_list("[1, 2]") == []
+ print("ok skill parts")
+
+
if __name__ == "__main__":
test_presets()
test_real_presets()
@@ -3756,4 +3786,5 @@ if __name__ == "__main__":
test_skill_tool_message()
test_skills_db()
test_skills_column_migration()
+ test_skill_parts()
print("\nall checks passed")