feat: Add skills feature for tool and chat calling (#351)

This commit is contained in:
Petr Mironychev
2026-05-19 09:46:50 +02:00
committed by GitHub
parent a3ad314cd4
commit 7483c78777
41 changed files with 1379 additions and 30 deletions

View File

@@ -0,0 +1,129 @@
// Copyright (C) 2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SkillsManager.hpp"
#include <QDir>
#include <QFileSystemWatcher>
#include "SkillsLoader.hpp"
namespace QodeAssist::Skills {
SkillsManager::SkillsManager(QObject *parent)
: QObject(parent)
, m_watcher(new QFileSystemWatcher(this))
{
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { reload(); });
}
void SkillsManager::configure(
const QString &projectPath,
const QStringList &globalRoots,
const QStringList &projectSubdirs)
{
if (m_projectPath == projectPath && m_globalRoots == globalRoots
&& m_projectSubdirs == projectSubdirs) {
return;
}
m_projectPath = projectPath;
m_globalRoots = globalRoots;
m_projectSubdirs = projectSubdirs;
reload();
}
QStringList SkillsManager::resolveRoots(
const QString &projectPath,
const QStringList &globalRoots,
const QStringList &projectSubdirs)
{
// Project-relative roots first so they win on a name collision.
QStringList roots;
if (!projectPath.isEmpty()) {
const QDir projectDir(projectPath);
const QString projectRoot = QDir::cleanPath(projectDir.absolutePath());
for (const QString &subdir : projectSubdirs) {
const QString resolved = QDir::cleanPath(projectDir.absoluteFilePath(subdir));
// Drop subdirs that escape the project root (e.g. "../../etc").
if (resolved == projectRoot
|| resolved.startsWith(projectRoot + QLatin1Char('/'))) {
roots << resolved;
}
}
}
for (const QString &root : globalRoots)
roots << QDir::cleanPath(root);
return roots;
}
void SkillsManager::reload()
{
const QStringList roots = resolveRoots(m_projectPath, m_globalRoots, m_projectSubdirs);
m_skills = SkillsLoader::scan(roots);
updateWatcher(roots);
emit skillsChanged();
}
void SkillsManager::updateWatcher(const QStringList &roots)
{
const QStringList watched = m_watcher->directories();
if (!watched.isEmpty())
m_watcher->removePaths(watched);
QStringList toWatch;
for (const QString &root : roots) {
if (QDir(root).exists())
toWatch << root;
}
for (const AgentSkill &skill : m_skills)
toWatch << skill.skillDir;
if (!toWatch.isEmpty())
m_watcher->addPaths(toWatch);
}
QVector<AgentSkill> SkillsManager::skills() const
{
return m_skills;
}
std::optional<AgentSkill> SkillsManager::findByName(const QString &name) const
{
for (const AgentSkill &skill : m_skills) {
if (skill.name == name)
return skill;
}
return std::nullopt;
}
QString SkillsManager::catalogText() const
{
QStringList entries;
for (const AgentSkill &skill : m_skills) {
if (!skill.enabled || skill.alwaysOn)
continue;
entries << QStringLiteral("- %1: %2").arg(skill.name, skill.description);
}
if (entries.isEmpty())
return {};
return QStringLiteral("# Available Skills\n"
"Specialized skills are available for the tasks below. When a "
"request matches a skill, call the load_skill tool with that "
"skill's name to load its full instructions, then follow them.\n\n")
+ entries.join('\n');
}
QString SkillsManager::alwaysOnBodies() const
{
QStringList bodies;
for (const AgentSkill &skill : m_skills) {
if (!skill.enabled || !skill.alwaysOn)
continue;
if (!skill.body.isEmpty())
bodies << skill.body;
}
return bodies.join(QStringLiteral("\n\n"));
}
} // namespace QodeAssist::Skills