import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AgentEndEvent, ExtensionAPI, ExtensionCommandContext, ExtensionContext, InputEvent, } from "@earendil-works/pi-coding-agent"; const DATA_DIR = path.join(os.homedir(), ".pi", "agent", "data", "pi-skill-recommender"); const GENERATED_SKILLS_DIR = path.join( os.homedir(), ".pi", "agent", "skills", "generated-by-skill-recommender", ); const STATE_FILE = path.join(DATA_DIR, "state.json"); const CONFIG_FILE = path.join(DATA_DIR, "config.json"); const LOCK_FILE = path.join(DATA_DIR, "state.lock"); const MAX_LOCK_WAIT_MS = 5000; const LOCK_RETRY_MS = 50; const STATE_VERSION = 1; const MAX_NORMALIZED_PROMPT_LENGTH = 400; interface Config { enabled: boolean; minRecurrences: number; minSessions: number; similarityThreshold: number; stepConsistencyMinSharedTools: number; stepConsistencyCoverage: number; snoozeWindowDays: number; maxExamplePromptsPerCandidate: number; maxObservations: number; } const DEFAULT_CONFIG: Config = { enabled: true, minRecurrences: 3, minSessions: 2, similarityThreshold: 0.55, stepConsistencyMinSharedTools: 2, stepConsistencyCoverage: 0.6, snoozeWindowDays: 14, maxExamplePromptsPerCandidate: 3, maxObservations: 2000, }; type CandidateStatus = "active" | "ignored" | "generated" | "promoted"; type NotifyLevel = "info" | "warning" | "error"; interface Observation { id: string; timestamp: string; sessionId: string; cwd: string; repoKey: string; normalizedPrompt: string; promptKeywords: string[]; pathTerms: string[]; toolNames: string[]; outcomeHints: string[]; } interface CandidateSeed { normalizedPrompt: string; promptKeywords: string[]; pathTerms: string[]; toolNames: string[]; repoKey: string; cwd: string; outcomeHints: string[]; } interface Candidate { id: string; createdAt: string; lastSeenAt: string; status: CandidateStatus; snoozedUntil?: string; ignoreReason?: string; duplicateOfSkillName?: string; generatedSkillName?: string; generatedSkillPath?: string; generatedAt?: string; promotedSkillPath?: string; promotedAt?: string; seed: CandidateSeed; observationIds: string[]; observationCount: number; sessionIds: string[]; keywordCounts: Record; pathTermCounts: Record; toolCounts: Record; repoCounts: Record; outcomeHintCounts: Record; examplePrompts: string[]; } interface RecommenderState { version: number; observations: Observation[]; candidates: Candidate[]; } interface PendingObservation { timestamp: string; sessionId: string; cwd: string; repoKey: string; normalizedPrompt: string; promptKeywords: string[]; pathTerms: string[]; outcomeHints: string[]; } interface CandidateProfile { repoKey: string; promptKeywords: string[]; pathTerms: string[]; toolNames: string[]; outcomeHints: string[]; } interface CandidateSummary { candidate: Candidate; profile: CandidateProfile; ready: boolean; sharedTools: string[]; visibleStatus: string; } interface KnownSkill { name: string; normalizedName: string; path: string; description: string; useWhenText: string; descriptionKeywords: string[]; useWhenKeywords: string[]; } interface DuplicateSkillMatch { skill: KnownSkill; reason: "name" | "description" | "use-when"; } const STOPWORDS = new Set([ "about", "after", "agent", "against", "also", "an", "and", "any", "are", "args", "around", "because", "been", "before", "being", "between", "both", "build", "candidate", "change", "changes", "check", "current", "done", "file", "files", "for", "from", "generate", "generated", "help", "here", "how", "implementation", "implement", "into", "just", "need", "next", "only", "path", "please", "repo", "return", "run", "should", "show", "skill", "status", "step", "steps", "task", "that", "the", "their", "them", "then", "there", "these", "this", "those", "through", "use", "using", "validate", "when", "with", "without", "workflow", "your", ]); const OUTCOME_HINT_TERMS = [ "address", "analyze", "automate", "build", "check", "compare", "debug", "deploy", "document", "fix", "generate", "implement", "investigate", "migrate", "refactor", "review", "summarize", "test", "triage", "validate", "verify", "watch", ]; const TOOL_STEP_HINTS: Record = { read: "read the relevant files first to ground the workflow", edit: "apply targeted file edits once the exact change is clear", write: "write new files or replace generated content deliberately", bash: "run the smallest validating shell commands needed for evidence", grep: "search for the narrowest code or text matches you need", find: "locate files or directories before assuming paths", ls: "inspect directory structure before editing nearby files", todo: "track multi-step work when the workflow spans several actions", ask: "ask for missing user decisions only when they block progress", ask_user_question: "gather structured user choices when the path is ambiguous", }; const pendingBySession = new Map(); export default function skillRecommender(pi: ExtensionAPI) { pi.on("input", async (event, ctx) => { await handleInput(event, ctx); return { action: "continue" as const }; }); pi.on("agent_end", async (event, ctx) => { await handleAgentEnd(pi, event, ctx).catch((error: unknown) => { console.error("[pi-skill-recommender] agent_end error:", error); }); }); pi.on("session_shutdown", async (_event, ctx) => { pendingBySession.delete(ctx.sessionManager.getSessionId()); }); pi.on("resources_discover", async () => { await ensureRuntimePaths(); return { skillPaths: [GENERATED_SKILLS_DIR], }; }); pi.registerCommand("skill-recommender-status", { description: "Show skill recommender paths, config, and candidate counts", handler: async (_args, ctx) => { const config = await loadConfig(); const state = await loadState(); const summaries = summarizeCandidates(state.candidates, config); const readyCount = summaries.filter((summary) => summary.ready && summary.visibleStatus === "active").length; const snoozedCount = summaries.filter((summary) => summary.visibleStatus.startsWith("snoozed")).length; const ignoredCount = summaries.filter((summary) => summary.candidate.status === "ignored").length; const generatedCount = summaries.filter((summary) => summary.candidate.status === "generated").length; const promotedCount = summaries.filter((summary) => summary.candidate.status === "promoted").length; const lines = [ "pi-skill-recommender", `enabled: ${config.enabled}`, `data: ${DATA_DIR}`, `generated skills: ${GENERATED_SKILLS_DIR}`, `observations stored: ${state.observations.length}`, `candidates: ${state.candidates.length} total | ${readyCount} ready | ${snoozedCount} snoozed | ${ignoredCount} ignored | ${generatedCount} generated | ${promotedCount} promoted`, `thresholds: recurrences>=${config.minRecurrences}, sessions>=${config.minSessions}, similarity>=${config.similarityThreshold}`, `step consistency: shared tools>=${config.stepConsistencyMinSharedTools}, coverage>=${config.stepConsistencyCoverage}`, ]; surfaceMessage(ctx, lines.join("\n"), "info"); }, }); pi.registerCommand("skill-recommender-candidates", { description: "List recurring workflow candidates", handler: async (_args, ctx) => { const config = await loadConfig(); const state = await loadState(); const summaries = summarizeCandidates(state.candidates, config) .filter((summary) => summary.candidate.status !== "ignored") .sort(compareCandidateSummaries); if (summaries.length === 0) { surfaceMessage(ctx, "No recurring workflow candidates yet.", "info"); return; } const lines = summaries.map((summary) => formatCandidateSummary(summary)); surfaceMessage(ctx, lines.join("\n\n"), "info"); }, }); pi.registerCommand("skill-recommender-generate", { description: "Generate a draft SKILL.md for a workflow-worthy candidate", handler: async (args, ctx) => { const candidateId = args.trim(); if (!candidateId) { surfaceMessage(ctx, "Usage: /skill-recommender-generate ", "warning"); return; } const config = await loadConfig(); const state = await loadState(); const candidate = state.candidates.find((entry) => entry.id === candidateId); if (!candidate) { surfaceMessage(ctx, `Unknown candidate: ${candidateId}`, "error"); return; } const summary = summarizeCandidate(candidate, config); if (!summary.ready) { surfaceMessage(ctx, `Candidate ${candidateId} is not workflow-worthy yet.`, "warning"); return; } const generation = await mutateState(async (mutableState) => { const mutableCandidate = mutableState.candidates.find((entry) => entry.id === candidateId); if (!mutableCandidate) { throw new Error(`Candidate ${candidateId} disappeared while generating.`); } const refreshedProfile = buildCandidateProfile(mutableCandidate); const refreshedSkillName = buildSkillName(refreshedProfile); const duplicate = findDuplicateSkill( refreshedProfile, refreshedSkillName, await collectKnownSkills(pi, ctx.cwd), mutableCandidate.generatedSkillPath, ); if (duplicate) { mutableCandidate.status = "ignored"; mutableCandidate.duplicateOfSkillName = duplicate.skill.name; mutableCandidate.ignoreReason = `duplicate ${duplicate.reason}: ${duplicate.skill.name}`; return { generated: false as const, duplicate }; } const refreshedSummary = summarizeCandidate(mutableCandidate, config); const skillDir = path.join(GENERATED_SKILLS_DIR, refreshedSkillName); const skillPath = path.join(skillDir, "SKILL.md"); const markdown = renderSkillMarkdown(refreshedSummary, config); await ensureDirectory(skillDir); await writeFileAtomic(skillPath, markdown); mutableCandidate.status = "generated"; mutableCandidate.generatedSkillName = refreshedSkillName; mutableCandidate.generatedSkillPath = skillPath; mutableCandidate.generatedAt = new Date().toISOString(); return { generated: true as const, skillName: refreshedSkillName, skillPath }; }); if (!generation.generated) { surfaceMessage( ctx, `Candidate ${candidateId} matches existing skill ${generation.duplicate.skill.name}; generation skipped.`, "warning", ); return; } surfaceMessage( ctx, `Generated ${generation.skillName} at ${generation.skillPath}. Run /reload to pick it up in the current session.`, "info", ); }, }); pi.registerCommand("skill-recommender-ignore", { description: "Ignore a candidate permanently", handler: async (args, ctx) => { const candidateId = args.trim(); if (!candidateId) { surfaceMessage(ctx, "Usage: /skill-recommender-ignore ", "warning"); return; } const updated = await mutateState(async (state) => { const candidate = state.candidates.find((entry) => entry.id === candidateId); if (!candidate) { throw new Error(`Unknown candidate: ${candidateId}`); } candidate.status = "ignored"; candidate.ignoreReason = "ignored by user"; candidate.snoozedUntil = undefined; return state; }).catch((error: unknown) => { surfaceMessage(ctx, String(error instanceof Error ? error.message : error), "error"); return null; }); if (!updated) return; surfaceMessage(ctx, `Ignored candidate ${candidateId}.`, "info"); }, }); pi.registerCommand("skill-recommender-snooze", { description: "Snooze a candidate for the configured window", handler: async (args, ctx) => { const candidateId = args.trim(); if (!candidateId) { surfaceMessage(ctx, "Usage: /skill-recommender-snooze ", "warning"); return; } const config = await loadConfig(); const snoozedUntil = new Date(Date.now() + config.snoozeWindowDays * 24 * 60 * 60 * 1000).toISOString(); const updated = await mutateState(async (state) => { const candidate = state.candidates.find((entry) => entry.id === candidateId); if (!candidate) { throw new Error(`Unknown candidate: ${candidateId}`); } candidate.snoozedUntil = snoozedUntil; if (candidate.status === "ignored") { candidate.status = "active"; candidate.ignoreReason = undefined; candidate.duplicateOfSkillName = undefined; } return state; }).catch((error: unknown) => { surfaceMessage(ctx, String(error instanceof Error ? error.message : error), "error"); return null; }); if (!updated) return; surfaceMessage(ctx, `Snoozed candidate ${candidateId} until ${snoozedUntil}.`, "info"); }, }); pi.registerCommand("skill-recommender-promote", { description: "Copy a generated candidate skill into the maintained global skill root", handler: async (args, ctx) => { const candidateId = args.trim(); if (!candidateId) { surfaceMessage(ctx, "Usage: /skill-recommender-promote ", "warning"); return; } const result = await mutateState(async (state) => { const candidate = state.candidates.find((entry) => entry.id === candidateId); if (!candidate) { throw new Error(`Unknown candidate: ${candidateId}`); } if (!candidate.generatedSkillName || !candidate.generatedSkillPath) { throw new Error(`Candidate ${candidateId} has no generated skill to promote.`); } const markdown = await fs.readFile(candidate.generatedSkillPath, "utf8"); const promotedDir = path.join(path.dirname(GENERATED_SKILLS_DIR), candidate.generatedSkillName); const promotedPath = path.join(promotedDir, "SKILL.md"); if (promotedPath !== candidate.generatedSkillPath && (await pathExists(promotedPath))) { throw new Error(`Maintained skill already exists at ${promotedPath}.`); } await ensureDirectory(promotedDir); await writeFileAtomic(promotedPath, markdown); const generatedDir = path.dirname(candidate.generatedSkillPath); await fs.unlink(candidate.generatedSkillPath).catch((error: unknown) => { console.warn("[pi-skill-recommender] promote cleanup unlink warning:", error); }); await fs.rmdir(generatedDir).catch((error: unknown) => { console.warn("[pi-skill-recommender] promote cleanup rmdir warning:", error); }); candidate.status = "promoted"; candidate.promotedSkillPath = promotedPath; candidate.promotedAt = new Date().toISOString(); candidate.generatedSkillPath = undefined; return { state, promotedPath, skillName: candidate.generatedSkillName }; }).catch((error: unknown) => { surfaceMessage(ctx, String(error instanceof Error ? error.message : error), "error"); return null; }); if (!result) return; surfaceMessage( ctx, `Promoted ${result.skillName} to ${result.promotedPath}. Run /reload to pick it up in the current session.`, "info", ); }, }); } async function handleInput(event: InputEvent, ctx: ExtensionContext): Promise { const config = await loadConfig(); if (!config.enabled) return; const rawText = event.text.trim(); if (!rawText || rawText.startsWith("/") || event.source === "extension") return; const normalizedPrompt = normalizeText(rawText).slice(0, MAX_NORMALIZED_PROMPT_LENGTH); if (!normalizedPrompt) return; const sessionId = ctx.sessionManager.getSessionId(); const repoRoot = await findRepoRoot(ctx.cwd); const repoKey = buildRepoKey(repoRoot, ctx.cwd); const pending: PendingObservation = { timestamp: new Date().toISOString(), sessionId, cwd: ctx.cwd, repoKey, normalizedPrompt, promptKeywords: extractTopTerms(normalizedPrompt, 12), pathTerms: extractPathTerms(ctx.cwd, repoRoot, 8), outcomeHints: detectOutcomeHints(rawText), }; const queue = pendingBySession.get(sessionId) ?? []; queue.push(pending); pendingBySession.set(sessionId, queue); } async function handleAgentEnd(pi: ExtensionAPI, event: AgentEndEvent, ctx: ExtensionContext): Promise { const config = await loadConfig(); if (!config.enabled) return; const sessionId = ctx.sessionManager.getSessionId(); const queue = pendingBySession.get(sessionId); if (!queue || queue.length === 0) return; const pending = queue.shift(); if (!pending) return; if (queue.length === 0) { pendingBySession.delete(sessionId); } else { pendingBySession.set(sessionId, queue); } const toolNames = extractOrderedToolNames(event.messages); const observation: Observation = { id: buildObservationId(pending), timestamp: pending.timestamp, sessionId: pending.sessionId, cwd: pending.cwd, repoKey: pending.repoKey, normalizedPrompt: pending.normalizedPrompt, promptKeywords: pending.promptKeywords, pathTerms: pending.pathTerms, toolNames, outcomeHints: pending.outcomeHints, }; await mutateState(async (state) => { state.observations.push(observation); trimObservations(state, config.maxObservations); const matchingCandidate = chooseBestCandidate(state.candidates, observation, config); if (matchingCandidate) { addObservationToCandidate(matchingCandidate, observation, config); return state; } const seedProfile = profileFromObservation(observation); const knownSkills = await collectKnownSkills(pi, ctx.cwd); const duplicate = findDuplicateSkill(seedProfile, buildSkillName(seedProfile), knownSkills); state.candidates.push(createCandidateFromObservation(observation, duplicate, config)); return state; }); } async function mutateState(mutator: (state: RecommenderState) => Promise | T): Promise { return withLock(async () => { await ensureRuntimePaths(); const state = await loadState(); const result = await mutator(state); if (isRecommenderState(result)) { await saveState(result); } else { await saveState(state); } return result; }); } function isRecommenderState(value: unknown): value is RecommenderState { return !!value && typeof value === "object" && Array.isArray((value as RecommenderState).observations); } async function ensureRuntimePaths(): Promise { await Promise.all([ensureDirectory(DATA_DIR), ensureDirectory(GENERATED_SKILLS_DIR)]); if (!(await pathExists(CONFIG_FILE))) { await writeFileAtomic(CONFIG_FILE, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`); } if (!(await pathExists(STATE_FILE))) { await writeFileAtomic(STATE_FILE, `${JSON.stringify(createDefaultState(), null, 2)}\n`); } } async function ensureDirectory(target: string): Promise { await fs.mkdir(target, { recursive: true }); } async function pathExists(target: string): Promise { try { await fs.access(target); return true; } catch { return false; } } async function loadConfig(): Promise { await ensureRuntimePaths(); const raw = await readJsonFile>(CONFIG_FILE, {}); return { enabled: coerceBoolean(raw.enabled, DEFAULT_CONFIG.enabled), minRecurrences: coercePositiveInteger(raw.minRecurrences, DEFAULT_CONFIG.minRecurrences), minSessions: coercePositiveInteger(raw.minSessions, DEFAULT_CONFIG.minSessions), similarityThreshold: coerceFraction(raw.similarityThreshold, DEFAULT_CONFIG.similarityThreshold), stepConsistencyMinSharedTools: coercePositiveInteger( raw.stepConsistencyMinSharedTools, DEFAULT_CONFIG.stepConsistencyMinSharedTools, ), stepConsistencyCoverage: coerceFraction(raw.stepConsistencyCoverage, DEFAULT_CONFIG.stepConsistencyCoverage), snoozeWindowDays: coercePositiveInteger(raw.snoozeWindowDays, DEFAULT_CONFIG.snoozeWindowDays), maxExamplePromptsPerCandidate: coercePositiveInteger( raw.maxExamplePromptsPerCandidate, DEFAULT_CONFIG.maxExamplePromptsPerCandidate, ), maxObservations: coercePositiveInteger(raw.maxObservations, DEFAULT_CONFIG.maxObservations), }; } async function loadState(): Promise { await ensureRuntimePaths(); const raw = await readJsonFile>(STATE_FILE, createDefaultState()); return sanitizeState(raw); } async function saveState(state: RecommenderState): Promise { await writeFileAtomic(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`); } async function readJsonFile(filePath: string, fallback: T): Promise { try { const contents = await fs.readFile(filePath, "utf8"); return JSON.parse(contents) as T; } catch { return fallback; } } async function writeFileAtomic(filePath: string, content: string): Promise { await ensureDirectory(path.dirname(filePath)); const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`; await fs.writeFile(tempPath, content, "utf8"); await fs.rename(tempPath, filePath); } async function withLock(callback: () => Promise): Promise { await ensureDirectory(path.dirname(LOCK_FILE)); const startedAt = Date.now(); while (true) { try { const handle = await fs.open(LOCK_FILE, "wx"); try { await handle.writeFile(`${process.pid}\n`, "utf8"); return await callback(); } finally { await handle.close(); await fs.unlink(LOCK_FILE).catch(() => undefined); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "EEXIST") throw error; if (await clearStaleLock()) { continue; } if (Date.now() - startedAt >= MAX_LOCK_WAIT_MS) { throw new Error(`Timed out waiting for ${LOCK_FILE}`); } await delay(LOCK_RETRY_MS); } } } async function clearStaleLock(): Promise { try { const contents = await fs.readFile(LOCK_FILE, "utf8"); const pid = Number.parseInt(contents.trim(), 10); if (!Number.isFinite(pid) || pid <= 0) { await fs.unlink(LOCK_FILE).catch(() => undefined); return true; } try { process.kill(pid, 0); return false; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ESRCH") { await fs.unlink(LOCK_FILE).catch(() => undefined); return true; } return false; } } catch { return false; } } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function createDefaultState(): RecommenderState { return { version: STATE_VERSION, observations: [], candidates: [], }; } function sanitizeState(raw: Partial | undefined): RecommenderState { const state = createDefaultState(); if (!raw || typeof raw !== "object") return state; const observations = Array.isArray(raw.observations) ? raw.observations : []; state.observations = observations .filter((entry): entry is Observation => !!entry && typeof entry === "object") .map((entry) => ({ id: typeof entry.id === "string" ? entry.id : randomUUID(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "unknown-session", cwd: typeof entry.cwd === "string" ? entry.cwd : ".", repoKey: typeof entry.repoKey === "string" ? entry.repoKey : "workspace", normalizedPrompt: typeof entry.normalizedPrompt === "string" ? entry.normalizedPrompt.slice(0, MAX_NORMALIZED_PROMPT_LENGTH) : "", promptKeywords: asStringArray(entry.promptKeywords), pathTerms: asStringArray(entry.pathTerms), toolNames: asStringArray(entry.toolNames), outcomeHints: asStringArray(entry.outcomeHints), })); const candidates = Array.isArray(raw.candidates) ? raw.candidates : []; state.candidates = candidates .filter((entry): entry is Candidate => !!entry && typeof entry === "object") .map((entry) => ({ id: typeof entry.id === "string" ? entry.id : `cand-${randomUUID()}`, createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(), lastSeenAt: typeof entry.lastSeenAt === "string" ? entry.lastSeenAt : new Date().toISOString(), status: isCandidateStatus(entry.status) ? entry.status : "active", snoozedUntil: typeof entry.snoozedUntil === "string" ? entry.snoozedUntil : undefined, ignoreReason: typeof entry.ignoreReason === "string" ? entry.ignoreReason : undefined, duplicateOfSkillName: typeof entry.duplicateOfSkillName === "string" ? entry.duplicateOfSkillName : undefined, generatedSkillName: typeof entry.generatedSkillName === "string" ? entry.generatedSkillName : undefined, generatedSkillPath: typeof entry.generatedSkillPath === "string" ? entry.generatedSkillPath : undefined, generatedAt: typeof entry.generatedAt === "string" ? entry.generatedAt : undefined, promotedSkillPath: typeof entry.promotedSkillPath === "string" ? entry.promotedSkillPath : undefined, promotedAt: typeof entry.promotedAt === "string" ? entry.promotedAt : undefined, seed: { normalizedPrompt: typeof entry.seed?.normalizedPrompt === "string" ? entry.seed.normalizedPrompt : "", promptKeywords: asStringArray(entry.seed?.promptKeywords), pathTerms: asStringArray(entry.seed?.pathTerms), toolNames: asStringArray(entry.seed?.toolNames), repoKey: typeof entry.seed?.repoKey === "string" ? entry.seed.repoKey : "workspace", cwd: typeof entry.seed?.cwd === "string" ? entry.seed.cwd : ".", outcomeHints: asStringArray(entry.seed?.outcomeHints), }, observationIds: asStringArray(entry.observationIds), observationCount: coercePositiveInteger(entry.observationCount, 0), sessionIds: asStringArray(entry.sessionIds), keywordCounts: asNumberRecord(entry.keywordCounts), pathTermCounts: asNumberRecord(entry.pathTermCounts), toolCounts: asNumberRecord(entry.toolCounts), repoCounts: asNumberRecord(entry.repoCounts), outcomeHintCounts: asNumberRecord(entry.outcomeHintCounts), examplePrompts: asStringArray(entry.examplePrompts), })); return state; } function isCandidateStatus(value: unknown): value is CandidateStatus { return value === "active" || value === "ignored" || value === "generated" || value === "promoted"; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string"); } function asNumberRecord(value: unknown): Record { if (!value || typeof value !== "object") return {}; return Object.fromEntries( Object.entries(value).filter( (entry): entry is [string, number] => typeof entry[0] === "string" && typeof entry[1] === "number" && entry[1] > 0, ), ); } function coerceBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } function coercePositiveInteger(value: unknown, fallback: number): number { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; } function coerceFraction(value: unknown, fallback: number): number { return typeof value === "number" && value >= 0 && value <= 1 ? value : fallback; } async function findRepoRoot(start: string): Promise { let current = path.resolve(start); while (true) { if (await pathExists(path.join(current, ".git"))) { return current; } const parent = path.dirname(current); if (parent === current) return undefined; current = parent; } } function buildRepoKey(repoRoot: string | undefined, cwd: string): string { const source = repoRoot ?? cwd; return normalizeSkillName(path.basename(source)) || "workspace"; } function normalizeText(input: string): string { return input .normalize("NFKD") .replace(/[^\p{L}\p{N}]+/gu, " ") .toLowerCase() .replace(/\s+/g, " ") .trim(); } function normalizeSkillName(input: string): string { const normalized = normalizeText(input) .replace(/[^a-z0-9\s-]/g, " ") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 64) .replace(/-+$/g, ""); return normalized; } function extractTopTerms(text: string, limit: number): string[] { const counts = new Map(); for (const token of text.split(" ")) { if (!token || token.length < 3 || STOPWORDS.has(token)) continue; counts.set(token, (counts.get(token) ?? 0) + 1); } return sortCounts(counts).slice(0, limit).map(([term]) => term); } function extractPathTerms(cwd: string, repoRoot: string | undefined, limit: number): string[] { const relevantPath = repoRoot ? path.relative(repoRoot, cwd) : cwd; const segments = relevantPath .split(path.sep) .flatMap((segment) => normalizeText(segment).split(" ")) .filter((segment) => segment && segment.length >= 2 && !STOPWORDS.has(segment)); const counts = new Map(); for (const segment of segments) { counts.set(segment, (counts.get(segment) ?? 0) + 1); } return sortCounts(counts).slice(0, limit).map(([term]) => term); } function detectOutcomeHints(input: string): string[] { const normalized = normalizeText(input); return OUTCOME_HINT_TERMS.filter((term) => normalized.includes(term)).slice(0, 6); } function sortCounts(counts: Map): Array<[string, number]> { return [...counts.entries()].sort((left, right) => { if (right[1] !== left[1]) return right[1] - left[1]; return left[0].localeCompare(right[0]); }); } function buildObservationId(observation: PendingObservation): string { const digest = createHash("sha1") .update(JSON.stringify({ timestamp: observation.timestamp, sessionId: observation.sessionId, prompt: observation.normalizedPrompt, repoKey: observation.repoKey, })) .digest("hex") .slice(0, 12); return `obs-${digest}`; } function extractOrderedToolNames(messages: AgentEndEvent["messages"]): string[] { const toolNames: string[] = []; for (const message of messages) { if ( !!message && typeof message === "object" && "role" in message && message.role === "toolResult" && "toolName" in message && typeof message.toolName === "string" ) { toolNames.push(message.toolName); } } return toolNames; } function profileFromObservation(observation: Observation): CandidateProfile { return { repoKey: observation.repoKey, promptKeywords: observation.promptKeywords, pathTerms: observation.pathTerms, toolNames: uniqueStrings(observation.toolNames), outcomeHints: observation.outcomeHints, }; } function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } function chooseBestCandidate(candidates: Candidate[], observation: Observation, config: Config): Candidate | undefined { const observationTools = new Set(uniqueStrings(observation.toolNames)); let bestCandidate: Candidate | undefined; let bestScore = -1; for (const candidate of candidates) { const profile = buildCandidateProfile(candidate); const overlappingTools = intersectionSize(observationTools, new Set(profile.toolNames)); if (overlappingTools === 0) continue; const score = scoreCandidate(observation, profile); if (score < config.similarityThreshold) continue; if ( score > bestScore || (score === bestScore && candidate.observationCount > (bestCandidate?.observationCount ?? -1)) ) { bestCandidate = candidate; bestScore = score; } } return bestCandidate; } function scoreCandidate(observation: Observation, profile: CandidateProfile): number { const keywordScore = jaccard(new Set(observation.promptKeywords), new Set(profile.promptKeywords)); const toolScore = jaccard(new Set(uniqueStrings(observation.toolNames)), new Set(profile.toolNames)); const repoPathScore = jaccard( new Set([observation.repoKey, ...observation.pathTerms]), new Set([profile.repoKey, ...profile.pathTerms]), ); return keywordScore * 0.5 + toolScore * 0.3 + repoPathScore * 0.2; } function jaccard(left: Set, right: Set): number { if (left.size === 0 && right.size === 0) return 1; const intersection = intersectionSize(left, right); const union = new Set([...left, ...right]).size; return union === 0 ? 0 : intersection / union; } function intersectionSize(left: Set, right: Set): number { let count = 0; for (const value of left) { if (right.has(value)) count += 1; } return count; } function createCandidateFromObservation( observation: Observation, duplicate: DuplicateSkillMatch | undefined, config: Config, ): Candidate { const candidate: Candidate = { id: buildCandidateId(observation), createdAt: observation.timestamp, lastSeenAt: observation.timestamp, status: duplicate ? "ignored" : "active", ignoreReason: duplicate ? `duplicate ${duplicate.reason}: ${duplicate.skill.name}` : undefined, duplicateOfSkillName: duplicate?.skill.name, seed: { normalizedPrompt: observation.normalizedPrompt, promptKeywords: observation.promptKeywords, pathTerms: observation.pathTerms, toolNames: observation.toolNames, repoKey: observation.repoKey, cwd: observation.cwd, outcomeHints: observation.outcomeHints, }, observationIds: [], observationCount: 0, sessionIds: [], keywordCounts: {}, pathTermCounts: {}, toolCounts: {}, repoCounts: {}, outcomeHintCounts: {}, examplePrompts: [], }; addObservationToCandidate(candidate, observation, config); return candidate; } function buildCandidateId(observation: Observation): string { const digest = createHash("sha1") .update( JSON.stringify({ promptKeywords: observation.promptKeywords, pathTerms: observation.pathTerms, toolNames: observation.toolNames, repoKey: observation.repoKey, normalizedPrompt: observation.normalizedPrompt, }), ) .digest("hex") .slice(0, 12); return `cand-${digest}`; } function addObservationToCandidate(candidate: Candidate, observation: Observation, config: Config): void { candidate.lastSeenAt = observation.timestamp; candidate.observationIds.push(observation.id); candidate.observationCount += 1; if (!candidate.sessionIds.includes(observation.sessionId)) { candidate.sessionIds.push(observation.sessionId); } incrementCounts(candidate.keywordCounts, observation.promptKeywords); incrementCounts(candidate.pathTermCounts, observation.pathTerms); incrementCounts(candidate.toolCounts, uniqueStrings(observation.toolNames)); incrementCounts(candidate.repoCounts, [observation.repoKey]); incrementCounts(candidate.outcomeHintCounts, observation.outcomeHints); if ( observation.normalizedPrompt && !candidate.examplePrompts.includes(observation.normalizedPrompt) && candidate.examplePrompts.length < config.maxExamplePromptsPerCandidate ) { candidate.examplePrompts.push(observation.normalizedPrompt); } if (candidate.snoozedUntil && Date.parse(candidate.snoozedUntil) <= Date.now()) { candidate.snoozedUntil = undefined; } } function incrementCounts(target: Record, values: string[]): void { for (const value of values) { if (!value) continue; target[value] = (target[value] ?? 0) + 1; } } function trimObservations(state: RecommenderState, maxObservations: number): void { if (state.observations.length <= maxObservations) return; state.observations = state.observations.slice(state.observations.length - maxObservations); } function buildCandidateProfile(candidate: Candidate): CandidateProfile { const repoKey = sortRecordEntries(candidate.repoCounts)[0]?.[0] ?? candidate.seed.repoKey; return { repoKey, promptKeywords: sortRecordEntries(candidate.keywordCounts).slice(0, 12).map(([value]) => value), pathTerms: sortRecordEntries(candidate.pathTermCounts).slice(0, 8).map(([value]) => value), toolNames: sortRecordEntries(candidate.toolCounts).map(([value]) => value), outcomeHints: sortRecordEntries(candidate.outcomeHintCounts).slice(0, 6).map(([value]) => value), }; } function sortRecordEntries(record: Record): Array<[string, number]> { return Object.entries(record).sort((left, right) => { if (right[1] !== left[1]) return right[1] - left[1]; return left[0].localeCompare(right[0]); }); } function summarizeCandidates(candidates: Candidate[], config: Config): CandidateSummary[] { return candidates.map((candidate) => summarizeCandidate(candidate, config)); } function summarizeCandidate(candidate: Candidate, config: Config): CandidateSummary { const profile = buildCandidateProfile(candidate); const sharedTools = getConsistentTools(candidate, config); return { candidate, profile, ready: candidate.observationCount >= config.minRecurrences && candidate.sessionIds.length >= config.minSessions && sharedTools.length >= config.stepConsistencyMinSharedTools, sharedTools, visibleStatus: buildVisibleStatus(candidate), }; } function getConsistentTools(candidate: Candidate, config: Config): string[] { if (candidate.observationCount === 0) return []; const threshold = Math.ceil(candidate.observationCount * config.stepConsistencyCoverage); return sortRecordEntries(candidate.toolCounts) .filter(([, count]) => count >= threshold) .map(([tool]) => tool); } function buildVisibleStatus(candidate: Candidate): string { if (candidate.status === "ignored") return "ignored"; if (candidate.snoozedUntil && Date.parse(candidate.snoozedUntil) > Date.now()) { return `snoozed until ${candidate.snoozedUntil}`; } return candidate.status; } function compareCandidateSummaries(left: CandidateSummary, right: CandidateSummary): number { if (left.ready !== right.ready) return left.ready ? -1 : 1; if (left.candidate.status !== right.candidate.status) { return left.candidate.status.localeCompare(right.candidate.status); } if (left.candidate.observationCount !== right.candidate.observationCount) { return right.candidate.observationCount - left.candidate.observationCount; } return right.candidate.lastSeenAt.localeCompare(left.candidate.lastSeenAt); } function formatCandidateSummary(summary: CandidateSummary): string { const { candidate, profile, ready, sharedTools, visibleStatus } = summary; const toolPreview = sharedTools.length > 0 ? sharedTools.slice(0, 5).join(", ") : profile.toolNames.slice(0, 5).join(", "); const keywordPreview = profile.promptKeywords.slice(0, 5).join(", ") || "(no strong keywords yet)"; return [ `${candidate.id} [${visibleStatus}]${ready ? " ready" : " collecting"}`, ` seen ${candidate.observationCount} times across ${candidate.sessionIds.length} sessions`, ` tools: ${toolPreview || "(no tools captured yet)"}`, ` keywords: ${keywordPreview}`, ` examples: ${candidate.examplePrompts.slice(0, 2).join(" | ") || "(no example prompts stored)"}`, ].join("\n"); } async function collectKnownSkills(pi: ExtensionAPI, cwd: string): Promise { const commandPaths = pi .getCommands() .filter((command) => command.source === "skill") .map((command) => command.sourceInfo.path); const skillRoots = [ path.join(os.homedir(), ".pi", "agent", "skills"), path.join(os.homedir(), ".agents", "skills"), ...collectProjectSkillRoots(cwd), ]; const skillPaths = new Set(); for (const skillPath of commandPaths) { if (skillPath.endsWith("SKILL.md") || skillPath.endsWith(".md")) { skillPaths.add(path.resolve(skillPath)); } } for (const root of skillRoots) { for (const skillPath of await collectSkillFiles(root)) { skillPaths.add(skillPath); } } const skills: KnownSkill[] = []; for (const skillPath of skillPaths) { try { const contents = await fs.readFile(skillPath, "utf8"); const frontmatter = parseFrontmatter(contents); const name = frontmatter.name || normalizeSkillName(path.basename(path.dirname(skillPath)) || path.basename(skillPath, ".md")); if (!name) continue; const description = frontmatter.description || ""; const useWhenText = extractUseWhenText(contents); skills.push({ name, normalizedName: normalizeSkillName(name), path: skillPath, description, useWhenText, descriptionKeywords: extractTopTerms(normalizeText(description), 12), useWhenKeywords: extractTopTerms(normalizeText(useWhenText), 12), }); } catch { // Ignore unreadable skills. } } return skills; } function collectProjectSkillRoots(cwd: string): string[] { const roots: string[] = []; let current = path.resolve(cwd); while (true) { roots.push(path.join(current, ".pi", "skills")); roots.push(path.join(current, ".agents", "skills")); const parent = path.dirname(current); if (parent === current) break; current = parent; } return uniqueStrings(roots); } async function collectSkillFiles(root: string): Promise { if (!(await pathExists(root))) return []; const results: string[] = []; const rootIsAgentSkills = root.endsWith(path.join(".agents", "skills")); async function walk(current: string, allowRootMarkdown: boolean): Promise { const skillFile = path.join(current, "SKILL.md"); if (current !== root && (await pathExists(skillFile))) { results.push(skillFile); return; } const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { if (entry.name === ".git" || entry.name === "node_modules") continue; const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { await walk(fullPath, false); } else if (allowRootMarkdown && !rootIsAgentSkills && entry.isFile() && entry.name.endsWith(".md")) { results.push(fullPath); } } } await walk(root, true); return uniqueStrings(results.map((entry) => path.resolve(entry))); } function parseFrontmatter(contents: string): Record { const match = contents.match(/^---\n([\s\S]*?)\n---\n?/); if (!match) return {}; const values: Record = {}; for (const line of match[1].split("\n")) { const index = line.indexOf(":"); if (index === -1) continue; const key = line.slice(0, index).trim(); const value = line.slice(index + 1).trim(); if (key && value) { values[key] = value.replace(/^['\"]|['\"]$/g, ""); } } return values; } function extractUseWhenText(contents: string): string { const match = contents.match(/use this skill when[^\n]*/i); return match?.[0] ?? ""; } function findDuplicateSkill( profile: CandidateProfile, skillName: string, knownSkills: KnownSkill[], currentGeneratedPath?: string, ): DuplicateSkillMatch | undefined { const normalizedName = normalizeSkillName(skillName); const candidateKeywords = new Set(profile.promptKeywords); if (candidateKeywords.size === 0) { return undefined; } for (const skill of knownSkills) { if (currentGeneratedPath && path.resolve(skill.path) === path.resolve(currentGeneratedPath)) { continue; } if (skill.normalizedName === normalizedName) { return { skill, reason: "name" }; } const descriptionOverlap = jaccard(candidateKeywords, new Set(skill.descriptionKeywords)); if (descriptionOverlap > 0.6) { return { skill, reason: "description" }; } const useWhenOverlap = jaccard(candidateKeywords, new Set(skill.useWhenKeywords)); if (useWhenOverlap > 0.6) { return { skill, reason: "use-when" }; } } return undefined; } function buildSkillName(profile: CandidateProfile): string { const tokens = uniqueStrings([ ...profile.promptKeywords, ...profile.pathTerms, ...profile.toolNames.map(normalizeSkillName), profile.repoKey, ]) .filter((token) => token && token !== "workspace") .slice(0, 4); const base = tokens.length > 0 ? tokens.join("-") : "generated-workflow"; const candidate = base.endsWith("workflow") ? base : `${base}-workflow`; return normalizeSkillName(candidate) || "generated-workflow"; } function renderSkillMarkdown(summary: CandidateSummary, config: Config): string { const { candidate, profile, sharedTools } = summary; const skillName = buildSkillName(profile); const description = buildSkillDescription(profile, sharedTools); const orderedTools = orderToolsForTemplate(candidate, sharedTools, profile.toolNames).slice(0, 6); const toolSteps = orderedTools.map((toolName) => { const detail = TOOL_STEP_HINTS[toolName] ?? `use ${toolName} where it repeatedly appeared in the observed workflow`; return `1. ${capitalize(detail)}.`; }); const examples = candidate.examplePrompts.length > 0 ? candidate.examplePrompts : [candidate.seed.normalizedPrompt].filter(Boolean); const keywordPreview = profile.promptKeywords.slice(0, 6).join(", ") || "recurring workflow"; const repoPhrase = profile.repoKey === "workspace" ? "this workspace" : `the ${profile.repoKey} repo`; const useWhenLine = buildUseWhenSentence(profile, sharedTools); return `--- name: ${skillName} description: ${JSON.stringify(description)} --- # ${toTitle(skillName)} ## Why this skill exists This generated skill captures a recurring workflow observed ${candidate.observationCount} times across ${candidate.sessionIds.length} sessions in ${repoPhrase}. ## Use this skill when ${useWhenLine} ## Common workflow 1. Start in ${candidate.seed.cwd} and confirm the task still matches this workflow. ${toolSteps.length > 0 ? toolSteps.join("\n") : "1. Inspect the current task and choose the smallest repeatable steps that match the observed workflow."} 1. Validate the result before finishing, especially when the task touches files, commands, or generated output. ## Common signals - Frequent tools: ${orderedTools.join(", ") || "none recorded yet"} - Recurring keywords: ${keywordPreview} - Outcome hints: ${profile.outcomeHints.join(", ") || "none inferred"} ## Example prompts ${examples.map((example) => `- ${example}`).join("\n")} ## Generated by - Candidate id: ${candidate.id} - First seen: ${candidate.createdAt} - Last seen: ${candidate.lastSeenAt} - Config threshold: minRecurrences=${config.minRecurrences}, minSessions=${config.minSessions}, coverage=${config.stepConsistencyCoverage} `; } function buildSkillDescription(profile: CandidateProfile, sharedTools: string[]): string { const focus = profile.promptKeywords.slice(0, 3).join(", ") || profile.repoKey; const tools = sharedTools.slice(0, 3).join(", ") || profile.toolNames.slice(0, 3).join(", ") || "the observed tools"; return `Repeats the ${profile.repoKey === "workspace" ? "workspace" : profile.repoKey} workflow for ${focus}. Use this skill when you need the same pattern with ${tools}.`; } function buildUseWhenSentence(profile: CandidateProfile, sharedTools: string[]): string { const action = profile.outcomeHints[0] ? `${profile.outcomeHints.slice(0, 2).join(" and ")}` : `handle ${profile.promptKeywords.slice(0, 2).join(" and ") || "a similar task"}`; const tools = sharedTools.slice(0, 3).join(", ") || profile.toolNames.slice(0, 3).join(", ") || "the same tools"; const repo = profile.repoKey === "workspace" ? "this workspace" : `the ${profile.repoKey} repo`; return `Use this skill when you need to ${action} in ${repo} and the workflow repeatedly depends on ${tools}.`; } function orderToolsForTemplate(candidate: Candidate, sharedTools: string[], fallbackTools: string[]): string[] { const priority = new Set(sharedTools); const orderedSeedTools = candidate.seed.toolNames.filter((tool) => priority.has(tool)); const remainingShared = sharedTools.filter((tool) => !orderedSeedTools.includes(tool)); return uniqueStrings([...orderedSeedTools, ...remainingShared, ...fallbackTools]); } function toTitle(value: string): string { return value .split("-") .filter(Boolean) .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) .join(" "); } function capitalize(value: string): string { return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1); } function surfaceMessage(ctx: ExtensionCommandContext, message: string, level: NotifyLevel): void { if (ctx.hasUI) { ctx.ui.notify(message, level); return; } const prefix = `[pi-skill-recommender:${level}]`; console.log(`${prefix} ${message}`); }