406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
/**
|
|
* skill-hub.ts — Indicizzazione, ricerca e caricamento dinamico delle skill
|
|
* condivise del package pi-skill-hub.
|
|
*
|
|
* - `skill_search` : ricerca testuale su frontmatter, corpo e references
|
|
* - `skill_info` : dettaglio di una skill (frontmatter + body + references)
|
|
* - `skill_sync` : ricostruisce l'indice (scan a mtime)
|
|
* - `before_agent_start`: iniezione di istruzioni brevi nel system prompt
|
|
*
|
|
* Il routing primario resta quello nativo di pi (description nel frontmatter);
|
|
* skill_search è il fallback per ricerca profonda o task non riconosciuti.
|
|
*/
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "typebox";
|
|
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { spawn, execFile } from "node:child_process";
|
|
|
|
const SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
const MAX_CHARS_PER_SKILL = 200_000;
|
|
const MAX_BODY_IN_RESULT = 12_000;
|
|
|
|
type SkillEntry = {
|
|
name: string;
|
|
dir: string;
|
|
skillMdPath: string;
|
|
description: string;
|
|
tags: string[];
|
|
body: string;
|
|
references: { path: string; name: string; text: string }[];
|
|
scripts: string[];
|
|
mtime: number;
|
|
};
|
|
|
|
type IndexState = {
|
|
entries: SkillEntry[];
|
|
fingerprints: Map<string, number>;
|
|
builtAt: number;
|
|
};
|
|
|
|
const state: { index?: IndexState } = {};
|
|
|
|
// ---------------------------------------------------------------- utilities
|
|
|
|
function tokenize(text: string): string[] {
|
|
return text
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9àèéìòù#._-]+/i)
|
|
.filter((t) => t.length > 1);
|
|
}
|
|
|
|
/** Parser frontmatter tollerante: name, description (inline o folded '>'), metadata (tags). */
|
|
function parseFrontmatter(raw: string): {
|
|
name: string;
|
|
description: string;
|
|
tags: string[];
|
|
body: string;
|
|
} {
|
|
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
const fm = m ? m[1] : "";
|
|
const body = m ? raw.slice(m[0].length) : raw;
|
|
|
|
let name = "";
|
|
let description = "";
|
|
let descMode = false;
|
|
let inMetadata = false;
|
|
const tags: string[] = [];
|
|
|
|
for (const line of fm.split(/\r?\n/)) {
|
|
const top = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
if (top) {
|
|
inMetadata = top[1] === "metadata";
|
|
if (top[1] === "name") {
|
|
name = top[2].trim();
|
|
descMode = false;
|
|
continue;
|
|
}
|
|
if (top[1] === "description") {
|
|
const rest = top[2].trim();
|
|
descMode = rest === ">" || rest === ">-" || rest === "|";
|
|
description = descMode ? "" : rest.replace(/^["']|["']$/g, "");
|
|
continue;
|
|
}
|
|
descMode = false;
|
|
continue;
|
|
}
|
|
if (descMode) {
|
|
description += (description ? " " : "") + line.trim();
|
|
continue;
|
|
}
|
|
if (inMetadata) {
|
|
const t = line.trim();
|
|
if (/^(tags|keywords):\s*/.test(t)) {
|
|
const rest = t.replace(/^(tags|keywords):\s*/, "");
|
|
if (rest) tags.push(...rest.split(/[,\s]+/).filter(Boolean));
|
|
else tags.push(...[]); // valori su righe successive
|
|
continue;
|
|
}
|
|
if (/^-\s*/.test(t)) tags.push(t.replace(/^-\s*/, ""));
|
|
}
|
|
}
|
|
return { name, description: description.trim(), tags, body };
|
|
}
|
|
|
|
function listDir(dir: string): string[] {
|
|
try {
|
|
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function readClamped(path: string): string {
|
|
try {
|
|
const buf = readFileSync(path);
|
|
return buf.length > MAX_CHARS_PER_SKILL
|
|
? buf.subarray(0, MAX_CHARS_PER_SKILL).toString("utf8")
|
|
: buf.toString("utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function fingerprint(dir: string): number {
|
|
let acc = 0;
|
|
try {
|
|
for (const name of listDir(dir)) {
|
|
const sdir = join(dir, name);
|
|
acc += statSync(join(sdir, "SKILL.md")).mtimeMs;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
/**
|
|
* Best-effort git pull (ff-only, 10s, non bloccante) del clone del package:
|
|
* riceve gli aggiornamenti/skill nuove pushate da altre macchine all'avvio
|
|
* della sessione. L'indice si ricostruisce da solo via fingerprint mtime.
|
|
*/
|
|
function autoPull(root: string): void {
|
|
try {
|
|
if (!existsSync(join(root, ".git"))) return;
|
|
const child = spawn("git", ["-C", root, "pull", "--ff-only", "-q"], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
timeout: 10_000,
|
|
});
|
|
child.unref();
|
|
} catch {
|
|
/* pull opportunistico: mai bloccare l'avvio */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- index
|
|
|
|
let index: IndexState | undefined;
|
|
|
|
function buildIndex(force = false): IndexState {
|
|
if (index && !force) {
|
|
const fp = fingerprint(SKILLS_DIR);
|
|
if (fp === index.fingerprints.get(SKILLS_DIR)) return index;
|
|
}
|
|
const entries: SkillEntry[] = [];
|
|
const fingerprints = new Map<string, number>();
|
|
for (const name of listDir(SKILLS_DIR)) {
|
|
const dir = join(SKILLS_DIR, name);
|
|
const skillMdPath = join(dir, "SKILL.md");
|
|
let raw = "";
|
|
try {
|
|
raw = readClamped(skillMdPath);
|
|
} catch {
|
|
continue; // no SKILL.md → non è una skill
|
|
}
|
|
const fm = parseFrontmatter(raw);
|
|
const references: SkillEntry["references"] = [];
|
|
try {
|
|
for (const r of readdirSync(join(dir, "references"))) {
|
|
if (!r.endsWith(".md")) continue;
|
|
const rpath = join(dir, "references", r);
|
|
references.push({ path: rpath, name: r, text: readClamped(rpath) });
|
|
}
|
|
} catch {
|
|
/* nessuna references/ */
|
|
}
|
|
entries.push({
|
|
name: fm.name || name,
|
|
dir,
|
|
skillMdPath,
|
|
description: fm.description,
|
|
tags: fm.tags,
|
|
body: raw,
|
|
references,
|
|
mtime: 0,
|
|
});
|
|
}
|
|
fingerprints.set(SKILLS_DIR, fingerprint(SKILLS_DIR));
|
|
index = { entries, fingerprints, builtAt: Date.now() };
|
|
return index;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- search
|
|
|
|
function searchSkills(query: string, limit: number): { entry: SkillEntry; score: number; excerpt: string }[] {
|
|
const idx = buildIndex();
|
|
const tokens = tokenize(query);
|
|
const out: { entry: SkillEntry; score: number; excerpt: string }[] = [];
|
|
for (const entry of idx.entries) {
|
|
const haystacks: [string, number][] = [
|
|
[entry.name, 5],
|
|
[entry.tags.join(" "), 4],
|
|
[entry.description, 3],
|
|
[(entry.body.match(/^#{1,3} .*$/gm) || []).join("\n"), 2],
|
|
[entry.body, 1],
|
|
[entry.references.map((r) => `${r.name}\n${r.text}`).join("\n"), 1],
|
|
];
|
|
let score = 0;
|
|
let bestExcerpt = "";
|
|
for (const tok of tokens) {
|
|
for (const [text, weight] of haystacks) {
|
|
const lower = text.toLowerCase();
|
|
let pos = 0;
|
|
let count = 0;
|
|
while ((pos = lower.indexOf(tok, pos)) !== -1 && count < 5) {
|
|
if (!bestExcerpt && weight >= 3) {
|
|
bestExcerpt = text.slice(Math.max(0, pos - 120), pos + 180).replace(/\s+/g, " ").trim();
|
|
}
|
|
score += weight;
|
|
count++;
|
|
pos += tok.length;
|
|
}
|
|
}
|
|
}
|
|
if (score > 0) out.push({ entry, score, excerpt: bestExcerpt });
|
|
}
|
|
out.sort((a, b) => b.score - a.score);
|
|
return out.slice(0, limit);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- extension
|
|
|
|
function gitExec(root: string, args: string[], timeoutMs = 30_000): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile("git", ["-C", root, ...args], { timeout: timeoutMs, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
if (err) reject(new Error(`git ${args.join(" ")} → ${String(stderr || "").trim()} | ${String(stdout || "").trim()}`));
|
|
else resolve(String(stdout || "").trim());
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sincronizza le skill sul repo remoto: pull --ff-only → add skills/ → commit
|
|
* (solo se ci sono modifiche) → push. Mai rebase/merge automatici.
|
|
*/
|
|
async function syncSkills(root: string, message?: string): Promise<string> {
|
|
if (!existsSync(join(root, ".git"))) {
|
|
throw new Error("Il package non è un clone git: sincronizzazione non disponibile.");
|
|
}
|
|
await gitExec(root, ["pull", "--ff-only", "-q"], 20_000);
|
|
await gitExec(root, ["add", "-A"]);
|
|
let committed = "";
|
|
try {
|
|
committed = await gitExec(root, [
|
|
"-c", "user.name=enne2", "-c", "user.email=enne2@git.enne2.net",
|
|
"commit", "-m", message || `skill-hub: aggiornamento skill (${new Date().toISOString().slice(0, 16)})`,
|
|
], 20_000);
|
|
} catch (err) {
|
|
const msg = String((err as Error).message);
|
|
if (!/nothing to commit|no changes added/i.test(msg)) throw err;
|
|
committed = "nessuna modifica da committare";
|
|
}
|
|
const push = committed.includes("nessuna modifica") ? "push saltato (nessun commit)" : await gitExec(root, ["push", "origin", "main"], 30_000);
|
|
return `pull --ff-only OK; ${committed}; push origin main OK` + (push ? ` (${push.slice(0, 120)})` : "");
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
// indicizza all'avvio della sessione ("ad ogni avvio l'indice si aggiorna")
|
|
pi.on("session_start", async () => {
|
|
try {
|
|
autoPull(PACKAGE_ROOT);
|
|
buildIndex(true);
|
|
} catch {
|
|
/* la scan avviene comunque lazy alla prima ricerca */
|
|
}
|
|
});
|
|
|
|
// istruzioni brevi nel system prompt (append, mai replace)
|
|
pi.on("before_agent_start", async (event) => {
|
|
const idx = buildIndex();
|
|
const names = idx.entries.map((e) => e.name);
|
|
if (names.length === 0) return;
|
|
const block = [
|
|
"## Skill Hub (pi-skill-hub)",
|
|
`Skill condivise installate (${names.length}): ${names.join(", ")}.`,
|
|
"Se il task richiede una competenza operativa specializzata e nessuna skill nota corrisponde, esegui skill_search prima di improvvisare; carica la skill scelta con /skill:<name> (o leggi il suo SKILL.md per il corpo completo). Le evidenze di esecuzione vanno registrate in qmem.",
|
|
"Per creare o modificare una skill: crea/aggiorna skills/<nome-kebab-case>/ nel package (SKILL.md con frontmatter name+description routing, references/ per i dettagli, scripts/ senza segreti né path macchina-specifici; SKILL.md ≤ 500 righe), poi sincronizza col tool skill_sync (pull --ff-only + add/commit/push su origin main; su conflitto ferma e chiedi all'utente). Le altre macchine ricevono col pull automatico all'avvio di sessione o con pi update --extensions. Guida completa: docs/AGGIUNGERE-SKILL.md nel package.",
|
|
].join("\n");
|
|
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "skill_search",
|
|
label: "Skill search",
|
|
description:
|
|
"Cerca nelle skill condivise (frontmatter, corpo e references) per nome, parola chiave o bisogno operativo. Ritorna le skill più rilevanti con estratti e indicazioni di caricamento. Usalo quando nessuna skill nota corrisponde al task o per ritrovare dettagli operativi (procedure, errori, recovery).",
|
|
parameters: Type.Object({
|
|
query: Type.String({ description: "Parole chiave o descrizione del bisogno operativo (anche in italiano)" }),
|
|
limit: Type.Optional(Type.Number({ description: "Max risultati (default 5)" })),
|
|
}),
|
|
async execute(_id, params) {
|
|
const hits = searchSkills(params.query, Math.max(1, params.limit ?? 5));
|
|
if (hits.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "Nessuna skill corrisponde alla ricerca." }],
|
|
details: { hits: 0 },
|
|
};
|
|
}
|
|
const text = hits
|
|
.map(
|
|
(h, i) =>
|
|
`${i + 1}. ${h.entry.name} (score ${h.score})\n ${h.entry.description}\n Caricamento: /skill:${h.entry.name} | SKILL.md: ${h.entry.skillMdPath}\n Estratto: ${h.excerpt}`
|
|
)
|
|
.join("\n\n");
|
|
return {
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
hits: hits.length,
|
|
skills: hits.map((h) => ({ name: h.entry.name, path: h.entry.skillMdPath, score: h.score })),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "skill_sync",
|
|
label: "Skill sync",
|
|
description:
|
|
"Sincronizza le skill condivise sul repo Git remoto (git.enne2.net/enne2/pi-skill-hub): pull --ff-only, add/commit di skills/, push su origin main. Usalo DOPO aver creato o modificato una skill (skills/<nome>/) per distribuirla alle altre macchine. Su conflitto/rete fallisce con messaggio: non fare rebase automatici, chiedi all'utente.",
|
|
parameters: Type.Object({
|
|
message: Type.Optional(Type.String({ description: "Messaggio di commit (default: skill-hub: aggiornamento skill <data>" })),
|
|
}),
|
|
async execute(_id, params) {
|
|
try {
|
|
const out = await syncSkills(PACKAGE_ROOT, params.message);
|
|
const idx = buildIndex(true);
|
|
return {
|
|
content: [{ type: "text", text: `Skill Hub sincronizzato. ${out}\nSkill indicizzate: ${idx.entries.map((e) => e.name).join(", ")}` }],
|
|
details: { synced: true, skills: idx.entries.map((e) => e.name) },
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: "text", text: `Sync fallita: ${(err as Error).message}\nNon fare rebase/merge automatici: risolvere con l'utente.` }],
|
|
details: { synced: false, error: (err as Error).message },
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "skill_info",
|
|
label: "Skill info",
|
|
description:
|
|
"Mostra il corpo completo di una skill (frontmatter, SKILL.md, elenco references). Usalo dopo skill_search per leggere la procedura prima di eseguire il task.",
|
|
parameters: Type.Object({
|
|
name: Type.String({ description: "Nome della skill (es. sap-timesheet)" }),
|
|
}),
|
|
async execute(_id, params) {
|
|
const idx = buildIndex();
|
|
const entry = idx.entries.find((e) => e.name === params.name || e.skillMdPath.endsWith(params.name));
|
|
if (!entry) {
|
|
return {
|
|
content: [{ type: "text", text: `Skill "${params.name}" non trovata. Usa skill_search per l'elenco.` }],
|
|
details: { found: false },
|
|
};
|
|
}
|
|
const body = entry.body.length > MAX_BODY_IN_RESULT ? entry.body.slice(0, MAX_BODY_IN_RESULT) + "\n…[troncato]" : entry.body;
|
|
const refs = entry.references.map((r) => `- ${r.path}`).join("\n") || "(nessuna)";
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `# ${entry.name}\n\n${entry.description}\n\nCaricamento: /skill:${entry.name}\n\n## SKILL.md\n${body}\n\n## References\n${refs}`,
|
|
},
|
|
],
|
|
details: { found: true, name: entry.name, path: entry.skillMdPath, references: entry.references.map((r) => r.path) },
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("skill-sync", {
|
|
description: "Skill Hub: pull remoto + push delle skill + ricostruzione indice",
|
|
handler: async (_name, ctx) => {
|
|
const idx = buildIndex(true);
|
|
try {
|
|
const out = await syncSkills(PACKAGE_ROOT);
|
|
ctx.ui.notify(`Skill Hub: ${out} — ${idx.entries.length} skill indicizzate`, "info");
|
|
} catch (err) {
|
|
ctx.ui.notify(`Skill Hub: sync fallita (${(err as Error).message})`, "warning");
|
|
}
|
|
},
|
|
});
|
|
} |