Files

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");
}
},
});
}