mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
3a8a8b2bea
- Fix skill store cross-root slug lookup: installed skills under registrySlug names (e.g., next-best-practices) now resolve when queried by engine name (e.g., nextjs). Full-body injection rate went from 5% to 78% in evals. - Fix on-demand install: remove dead hooks.json registrations (library module with no entry point), keep inline calls from pretooluse/user-prompt-submit. Non-registry skills now dedup normally instead of wastefully reinjectiing summary-only content that can never upgrade. - Add suppressWhenProjectFacts to engine rules: next-upgrade and turbopack suppressed in greenfield mode (was 12 useless injections per session). - Add deriveRegistryInstallSet to orchestrator-install-plan (was missing export that crashed on-demand-skill-install on every invocation). - Sync all engine rule registries from skills.sh API via build-registry.ts. 22 skills now have registry backing across 11 official Vercel repos. Exclude vercel-labs/vercel-plugin and vercel/vercel-plugin self-references. - Add build:registry to the build pipeline (hooks → registry → manifest). - Create 7 new engine rules: electron, vercel-composition-patterns, vercel-react-native-skills, web-design-guidelines, deploy-to-vercel, vercel-react-view-transitions, workflow-init. - Add .claude/skills/ to .gitignore (installed skill caches). - Add Verification Checklist to CLAUDE.md.
1112 lines
40 KiB
JavaScript
Executable File
1112 lines
40 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
// hooks/src/user-prompt-submit-skill-inject.mts
|
|
import { execFileSync } from "child_process";
|
|
import { readFileSync, realpathSync } from "fs";
|
|
import { resolve } from "path";
|
|
import { fileURLToPath } from "url";
|
|
import {
|
|
appendAuditLog,
|
|
listSessionKeys,
|
|
pluginRoot as resolvePluginRoot,
|
|
readSessionFile,
|
|
syncSessionFileFromClaims,
|
|
tryClaimSessionKey,
|
|
writeSessionFile
|
|
} from "./hook-env.mjs";
|
|
import { loadSkills, injectSkills, applyCoInjectRules, parseFactSet } from "./pretooluse-skill-inject.mjs";
|
|
import { loadRegistrySkillMetadata } from "./registry-skill-metadata.mjs";
|
|
import { buildSkillsAddCommand } from "./skills-cli-command.mjs";
|
|
import { triggerOnDemandInstall } from "./on-demand-skill-install.mjs";
|
|
import {
|
|
COMPACTION_REINJECT_MIN_PRIORITY,
|
|
parseSeenSkills,
|
|
mergeSeenSkillStates,
|
|
mergeSeenSkillStatesWithCompactionReset,
|
|
buildDocsBlock
|
|
} from "./patterns.mjs";
|
|
import { normalizePromptText, compilePromptSignals, matchPromptWithReason, scorePromptWithLexical, classifyTroubleshootingIntent, lexicalFallbackMeetsFloor } from "./prompt-patterns.mjs";
|
|
import { searchSkills, initializeLexicalIndex } from "./lexical-index.mjs";
|
|
import { analyzePrompt } from "./prompt-analysis.mjs";
|
|
import { createLogger, logDecision } from "./logger.mjs";
|
|
import { trackBaseEvents } from "./telemetry.mjs";
|
|
var MAX_SKILLS = 10;
|
|
var DEFAULT_INJECTION_BUDGET_BYTES = 1e5;
|
|
var MIN_PROMPT_LENGTH = 10;
|
|
var PLUGIN_ROOT = resolvePluginRoot();
|
|
var SKILL_INJECTION_VERSION = 1;
|
|
var ENV_SEEN_SKILLS_KEY = "VERCEL_PLUGIN_SEEN_SKILLS";
|
|
var ENV_CONTEXT_COMPACTED_KEY = "VERCEL_PLUGIN_CONTEXT_COMPACTED";
|
|
var DEFAULT_PROMPT_MIN_SCORE = 6;
|
|
var PROJECT_CONTEXT_PROMPT_SCORE_BOOST = 3;
|
|
var DOMINANT_TOPIC_SCORE_THRESHOLD = 600;
|
|
var DOMINANT_TOPIC_MIN_SCORE = 50;
|
|
var INVESTIGATION_COMPANION_SKILLS = [
|
|
"workflow",
|
|
"agent-browser-verify",
|
|
"vercel-cli"
|
|
];
|
|
var log = createLogger();
|
|
function getSeenSkillsEnv() {
|
|
return typeof process.env[ENV_SEEN_SKILLS_KEY] === "string" ? process.env[ENV_SEEN_SKILLS_KEY] : "";
|
|
}
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
function nonEmptyString(value) {
|
|
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
}
|
|
function detectPromptHookPlatform(input) {
|
|
if ("conversation_id" in input || "cursor_version" in input) {
|
|
return "cursor";
|
|
}
|
|
return "claude-code";
|
|
}
|
|
function detectPromptHookPlatformFromRaw(raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (isRecord(parsed)) {
|
|
return detectPromptHookPlatform(parsed);
|
|
}
|
|
} catch {
|
|
}
|
|
return "claude-code";
|
|
}
|
|
function resolvePromptSessionId(input, env) {
|
|
return nonEmptyString(input.session_id) ?? nonEmptyString(input.conversation_id) ?? nonEmptyString(env.SESSION_ID);
|
|
}
|
|
function resolvePromptCwd(input, env) {
|
|
const workspaceRoot = Array.isArray(input.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null;
|
|
return nonEmptyString(input.cwd) ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) ?? nonEmptyString(env.CURSOR_PROJECT_DIR) ?? nonEmptyString(env.CLAUDE_PROJECT_ROOT) ?? process.cwd();
|
|
}
|
|
function resolvePromptText(input) {
|
|
return nonEmptyString(input.prompt) ?? nonEmptyString(input.message) ?? "";
|
|
}
|
|
function formatEmptyOutput(platform, env) {
|
|
if (platform === "cursor") {
|
|
const output = { continue: true };
|
|
if (env && Object.keys(env).length > 0) {
|
|
output.env = env;
|
|
}
|
|
return JSON.stringify(output);
|
|
}
|
|
return "{}";
|
|
}
|
|
function getInjectionBudget() {
|
|
const envVal = process.env.VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET;
|
|
if (envVal != null && envVal !== "") {
|
|
const parsed = parseInt(envVal, 10);
|
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
}
|
|
return DEFAULT_INJECTION_BUDGET_BYTES;
|
|
}
|
|
function capturePromptEnvSnapshot(env = process.env) {
|
|
return {
|
|
[ENV_SEEN_SKILLS_KEY]: env[ENV_SEEN_SKILLS_KEY],
|
|
[ENV_CONTEXT_COMPACTED_KEY]: env[ENV_CONTEXT_COMPACTED_KEY]
|
|
};
|
|
}
|
|
function finalizePromptEnvUpdates(platform, before, env = process.env) {
|
|
if (platform !== "cursor") return void 0;
|
|
const updates = {};
|
|
for (const key of [ENV_SEEN_SKILLS_KEY, ENV_CONTEXT_COMPACTED_KEY]) {
|
|
const nextValue = env[key];
|
|
if (typeof nextValue === "string" && nextValue !== before[key]) {
|
|
updates[key] = nextValue;
|
|
}
|
|
}
|
|
return Object.keys(updates).length > 0 ? updates : void 0;
|
|
}
|
|
function resolvePromptSeenSkillState(sessionId, skillMap) {
|
|
const dedupOff = process.env.VERCEL_PLUGIN_HOOK_DEDUP === "off";
|
|
const hasFileDedup = !dedupOff && !!sessionId;
|
|
const seenEnv = getSeenSkillsEnv();
|
|
const seenClaims = hasFileDedup ? listSessionKeys(sessionId, "seen-skills").join(",") : "";
|
|
const seenFile = hasFileDedup ? readSessionFile(sessionId, "seen-skills") : "";
|
|
const seenStateResult = dedupOff ? {
|
|
seenEnv,
|
|
seenState: hasFileDedup ? mergeSeenSkillStates(seenFile, seenClaims) : seenEnv,
|
|
compactionResetApplied: false,
|
|
clearedSkills: []
|
|
} : mergeSeenSkillStatesWithCompactionReset(seenEnv, seenFile, seenClaims, {
|
|
sessionId: hasFileDedup ? sessionId : void 0,
|
|
includeEnv: !hasFileDedup,
|
|
skillMap
|
|
});
|
|
const seenState = seenStateResult.seenState;
|
|
if (hasFileDedup) {
|
|
writeSessionFile(sessionId, "seen-skills", seenState);
|
|
}
|
|
return {
|
|
dedupOff,
|
|
hasFileDedup,
|
|
seenClaims,
|
|
seenFile,
|
|
seenEnv: seenStateResult.seenEnv,
|
|
seenState,
|
|
compactionResetApplied: seenStateResult.compactionResetApplied,
|
|
clearedSkills: seenStateResult.clearedSkills
|
|
};
|
|
}
|
|
function syncPromptSeenSkillClaims(sessionId, loadedSkills) {
|
|
for (const skill of loadedSkills) {
|
|
tryClaimSessionKey(sessionId, "seen-skills", skill);
|
|
}
|
|
return syncSessionFileFromClaims(sessionId, "seen-skills");
|
|
}
|
|
function parsePromptInput(raw, logger, env = process.env) {
|
|
const l = logger || log;
|
|
const trimmed = (raw || "").trim();
|
|
if (!trimmed) {
|
|
l.debug("stdin-empty", {});
|
|
return null;
|
|
}
|
|
let input;
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (!isRecord(parsed)) {
|
|
l.debug("stdin-not-object", {});
|
|
return null;
|
|
}
|
|
input = parsed;
|
|
} catch (err) {
|
|
l.issue("STDIN_PARSE_FAIL", "Failed to parse stdin as JSON", "Verify stdin contains valid JSON", { error: String(err) });
|
|
return null;
|
|
}
|
|
const platform = detectPromptHookPlatform(input);
|
|
const prompt = resolvePromptText(input);
|
|
const sessionId = resolvePromptSessionId(input, env);
|
|
const cwd = resolvePromptCwd(input, env);
|
|
if (prompt.length < MIN_PROMPT_LENGTH) {
|
|
l.debug("prompt-too-short", { length: prompt.length, min: MIN_PROMPT_LENGTH });
|
|
return null;
|
|
}
|
|
l.debug("input-parsed", {
|
|
promptLength: prompt.length,
|
|
sessionId,
|
|
cwd,
|
|
platform
|
|
});
|
|
return { prompt, platform, sessionId, cwd };
|
|
}
|
|
function parseLikelySkillsEnv(envValue = process.env.VERCEL_PLUGIN_LIKELY_SKILLS) {
|
|
if (typeof envValue !== "string" || envValue.trim() === "") {
|
|
return /* @__PURE__ */ new Set();
|
|
}
|
|
return new Set(
|
|
envValue.split(",").map((skill) => skill.trim()).filter((skill) => skill.length > 0)
|
|
);
|
|
}
|
|
function getPromptSignalMinScore(skillConfig) {
|
|
const minScore = skillConfig?.promptSignals?.minScore;
|
|
return typeof minScore === "number" && !Number.isNaN(minScore) ? minScore : DEFAULT_PROMPT_MIN_SCORE;
|
|
}
|
|
function formatPromptScore(score) {
|
|
return Number.isInteger(score) ? String(score) : score.toFixed(1);
|
|
}
|
|
function extractLexicalScore(reason) {
|
|
const match = reason.match(/lexical [^(]*\((?:raw |score )([0-9]+(?:\.[0-9]+)?)/);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
function extractBelowThresholdScore(reason) {
|
|
const match = reason.match(/below threshold: score (-?[0-9]+(?:\.[0-9]+)?)/);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
function applyLexicalFallbackFloor(entry) {
|
|
const lexicalScore = extractLexicalScore(entry.reason);
|
|
if (lexicalScore == null || lexicalFallbackMeetsFloor(lexicalScore) || entry.score === -Infinity) {
|
|
return entry;
|
|
}
|
|
const exactScore = extractBelowThresholdScore(entry.reason);
|
|
const score = exactScore ?? entry.score;
|
|
return {
|
|
...entry,
|
|
score,
|
|
matched: score >= entry.minScore,
|
|
reason: `${entry.reason}; lexical floor rejected (raw ${formatPromptScore(lexicalScore)} < 20)`
|
|
};
|
|
}
|
|
function applyProjectContextBoost(entry, likelySkills) {
|
|
if (!likelySkills.has(entry.skill) || entry.score === -Infinity) {
|
|
return entry;
|
|
}
|
|
const boostedScore = entry.score + PROJECT_CONTEXT_PROMPT_SCORE_BOOST;
|
|
const boostReason = `project-context +${PROJECT_CONTEXT_PROMPT_SCORE_BOOST} (${formatPromptScore(entry.score)} -> ${formatPromptScore(boostedScore)})`;
|
|
const reason = entry.reason.startsWith("below threshold:") && boostedScore >= entry.minScore ? boostReason : entry.reason ? `${entry.reason}; ${boostReason}` : boostReason;
|
|
return {
|
|
...entry,
|
|
score: boostedScore,
|
|
reason,
|
|
matched: boostedScore >= entry.minScore
|
|
};
|
|
}
|
|
function applyDominantTopicSuppression(entry, topScore) {
|
|
if (!entry.matched || !Number.isFinite(entry.score) || entry.score >= DOMINANT_TOPIC_MIN_SCORE || topScore < DOMINANT_TOPIC_SCORE_THRESHOLD) {
|
|
return entry;
|
|
}
|
|
return {
|
|
...entry,
|
|
matched: false,
|
|
suppressed: true,
|
|
reason: `${entry.reason}; suppressed by dominant topic (${formatPromptScore(topScore)} >= ${DOMINANT_TOPIC_SCORE_THRESHOLD}, score < ${DOMINANT_TOPIC_MIN_SCORE})`
|
|
};
|
|
}
|
|
function applyPromptScoreAdjustments(entries, logger) {
|
|
const l = logger || log;
|
|
const likelySkills = parseLikelySkillsEnv();
|
|
const lexicalFloorRejected = [];
|
|
const flooredEntries = entries.map((entry) => {
|
|
const adjusted = applyLexicalFallbackFloor(entry);
|
|
if (adjusted !== entry) {
|
|
lexicalFloorRejected.push(entry.skill);
|
|
}
|
|
return adjusted;
|
|
});
|
|
const boostedSkills = [];
|
|
if (lexicalFloorRejected.length > 0) {
|
|
l.debug("prompt-lexical-floor-rejected", {
|
|
minRawScore: 20,
|
|
rejectedSkills: lexicalFloorRejected
|
|
});
|
|
}
|
|
const boostedEntries = flooredEntries.map((entry) => {
|
|
const boosted = applyProjectContextBoost(entry, likelySkills);
|
|
if (boosted !== entry) {
|
|
boostedSkills.push(entry.skill);
|
|
}
|
|
return boosted;
|
|
});
|
|
if (boostedSkills.length > 0) {
|
|
l.debug("prompt-project-context-boost", {
|
|
boost: PROJECT_CONTEXT_PROMPT_SCORE_BOOST,
|
|
boostedSkills
|
|
});
|
|
}
|
|
const topScore = boostedEntries.reduce((max, entry) => {
|
|
if (Number.isFinite(entry.score) && entry.score > max) {
|
|
return entry.score;
|
|
}
|
|
return max;
|
|
}, -Infinity);
|
|
if (topScore < DOMINANT_TOPIC_SCORE_THRESHOLD) {
|
|
return boostedEntries;
|
|
}
|
|
const suppressedSkills = [];
|
|
const adjustedEntries = boostedEntries.map((entry) => {
|
|
const adjusted = applyDominantTopicSuppression(entry, topScore);
|
|
if (adjusted !== entry) {
|
|
suppressedSkills.push(entry.skill);
|
|
}
|
|
return adjusted;
|
|
});
|
|
if (suppressedSkills.length > 0) {
|
|
l.debug("prompt-dominant-topic-suppression", {
|
|
topScore,
|
|
minScore: DOMINANT_TOPIC_MIN_SCORE,
|
|
suppressedSkills
|
|
});
|
|
}
|
|
return adjustedEntries;
|
|
}
|
|
function sortPromptScoreStates(entries) {
|
|
entries.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
return a.skill.localeCompare(b.skill);
|
|
});
|
|
}
|
|
function estimatePromptSkillSize(skillConfig) {
|
|
return skillConfig?.summary ? Math.max(skillConfig.summary.length * 10, 500) : 500;
|
|
}
|
|
function rerankPromptAnalysisReport(report, skillMap, maxSkills, budgetBytes) {
|
|
const ranked = Object.entries(report.perSkillResults).filter(([, result]) => result.matched).map(([skill, result]) => ({
|
|
skill,
|
|
score: result.score,
|
|
reason: result.reason,
|
|
priority: skillMap[skill]?.priority ?? 0,
|
|
matched: true,
|
|
minScore: getPromptSignalMinScore(skillMap[skill]),
|
|
suppressed: result.suppressed
|
|
}));
|
|
sortPromptScoreStates(ranked);
|
|
const dedupDisabled = report.dedupState.strategy === "disabled";
|
|
const seenSkills = new Set(report.dedupState.seenSkills);
|
|
const filteredByDedup = [];
|
|
const afterDedup = ranked.filter((entry) => {
|
|
if (!dedupDisabled && seenSkills.has(entry.skill)) {
|
|
filteredByDedup.push(entry.skill);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
report.dedupState.filteredByDedup = filteredByDedup;
|
|
report.droppedByCap = afterDedup.slice(maxSkills).map((entry) => entry.skill);
|
|
const droppedByBudget = [];
|
|
const selectedSkills = [];
|
|
let usedBytes = 0;
|
|
for (const entry of afterDedup.slice(0, maxSkills)) {
|
|
const estimatedSize = estimatePromptSkillSize(skillMap[entry.skill]);
|
|
if (usedBytes + estimatedSize > budgetBytes && selectedSkills.length > 0) {
|
|
droppedByBudget.push(entry.skill);
|
|
continue;
|
|
}
|
|
usedBytes += estimatedSize;
|
|
selectedSkills.push(entry.skill);
|
|
}
|
|
report.selectedSkills = selectedSkills;
|
|
report.droppedByBudget = droppedByBudget;
|
|
}
|
|
function applyPromptScoreAdjustmentsToReport(report, skillMap, logger, options) {
|
|
const scoredEntries = Object.entries(report.perSkillResults).map(
|
|
([skill, result]) => ({
|
|
skill,
|
|
score: result.score,
|
|
reason: result.reason,
|
|
priority: skillMap[skill]?.priority ?? 0,
|
|
matched: result.matched,
|
|
minScore: getPromptSignalMinScore(skillMap[skill]),
|
|
suppressed: result.suppressed
|
|
})
|
|
);
|
|
const adjustedEntries = applyPromptScoreAdjustments(scoredEntries, logger);
|
|
for (const entry of adjustedEntries) {
|
|
report.perSkillResults[entry.skill] = {
|
|
score: entry.score,
|
|
reason: entry.reason,
|
|
matched: entry.matched,
|
|
suppressed: entry.suppressed
|
|
};
|
|
}
|
|
rerankPromptAnalysisReport(
|
|
report,
|
|
skillMap,
|
|
options?.maxSkills ?? MAX_SKILLS,
|
|
options?.budgetBytes ?? report.budgetBytes
|
|
);
|
|
return report;
|
|
}
|
|
function matchPromptSignals(normalizedPrompt, skills, logger, options) {
|
|
const l = logger || log;
|
|
const lexical = options?.lexical ?? false;
|
|
const { skillMap } = skills;
|
|
const scoredEntries = [];
|
|
const lexicalHits = lexical ? searchSkills(normalizedPrompt) : void 0;
|
|
for (const [skill, config] of Object.entries(skillMap)) {
|
|
if (!config.promptSignals) continue;
|
|
const compiled = compilePromptSignals(config.promptSignals);
|
|
if (lexical) {
|
|
const lexResult = scorePromptWithLexical(normalizedPrompt, skill, compiled, lexicalHits);
|
|
const lexicalFloorRejected = lexResult.source !== "exact" && !lexicalFallbackMeetsFloor(lexResult.lexicalScore);
|
|
const isMatched = lexResult.score >= compiled.minScore && !lexicalFloorRejected;
|
|
const reason = lexResult.source === "exact" ? matchPromptWithReason(normalizedPrompt, compiled).reason : `${matchPromptWithReason(normalizedPrompt, compiled).reason}; lexical ${lexResult.source} (score ${lexResult.lexicalScore.toFixed(1)}, tier ${lexResult.boostTier ?? "none"})${lexicalFloorRejected ? "; lexical floor rejected" : ""}`;
|
|
scoredEntries.push({
|
|
skill,
|
|
score: lexResult.score,
|
|
reason,
|
|
priority: config.priority,
|
|
matched: isMatched,
|
|
minScore: compiled.minScore,
|
|
suppressed: lexResult.score === -Infinity
|
|
});
|
|
} else {
|
|
const result = matchPromptWithReason(normalizedPrompt, compiled);
|
|
scoredEntries.push({
|
|
skill,
|
|
matched: result.matched,
|
|
score: result.score,
|
|
reason: result.reason,
|
|
priority: config.priority,
|
|
minScore: compiled.minScore,
|
|
suppressed: result.score === -Infinity
|
|
});
|
|
}
|
|
}
|
|
const adjustedEntries = applyPromptScoreAdjustments(scoredEntries, l);
|
|
for (const entry of adjustedEntries) {
|
|
l.trace("prompt-signal-eval", {
|
|
skill: entry.skill,
|
|
matched: entry.matched,
|
|
score: entry.score,
|
|
reason: entry.reason,
|
|
suppressed: entry.suppressed
|
|
});
|
|
}
|
|
const matches = adjustedEntries.filter((entry) => entry.matched).map(({ skill, score, reason, priority }) => ({
|
|
skill,
|
|
score,
|
|
reason,
|
|
priority
|
|
}));
|
|
sortPromptScoreStates(matches);
|
|
l.debug("prompt-matches", {
|
|
totalWithSignals: Object.values(skillMap).filter((c) => c.promptSignals).length,
|
|
matched: matches.map((m) => ({ skill: m.skill, score: m.score })),
|
|
lexical
|
|
});
|
|
return matches;
|
|
}
|
|
function selectInvestigationCompanion(selectedSkills, perSkillResults) {
|
|
if (!selectedSkills.includes("investigation-mode")) {
|
|
return { companion: null, reason: "investigation-mode not selected" };
|
|
}
|
|
let bestCompanion = null;
|
|
let bestScore = -Infinity;
|
|
for (const candidate of INVESTIGATION_COMPANION_SKILLS) {
|
|
const result = perSkillResults[candidate];
|
|
if (result && result.matched && result.score > bestScore) {
|
|
bestScore = result.score;
|
|
bestCompanion = candidate;
|
|
}
|
|
}
|
|
if (!bestCompanion) {
|
|
return { companion: null, reason: "no companion scored high enough" };
|
|
}
|
|
return {
|
|
companion: bestCompanion,
|
|
reason: `companion "${bestCompanion}" scored ${bestScore}`
|
|
};
|
|
}
|
|
function deduplicateAndInject(matches, skills, logger, platform) {
|
|
const l = logger || log;
|
|
const dedupOff = process.env.VERCEL_PLUGIN_HOOK_DEDUP === "off";
|
|
const seenState = getSeenSkillsEnv();
|
|
const injectedSkills = dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState);
|
|
const budget = getInjectionBudget();
|
|
const allMatched = matches.map((m) => m.skill);
|
|
const newMatches = dedupOff ? matches : matches.filter((m) => !injectedSkills.has(m.skill));
|
|
if (newMatches.length === 0) {
|
|
l.debug("all-prompt-matches-deduped", { matched: allMatched, seen: [...injectedSkills] });
|
|
return { parts: [], loaded: [], summaryOnly: [], droppedByCap: [], droppedByBudget: [], matchedSkills: allMatched };
|
|
}
|
|
const rankedSkills = newMatches.slice(0, MAX_SKILLS).map((m) => m.skill);
|
|
const droppedByCap = newMatches.slice(MAX_SKILLS).map((m) => m.skill);
|
|
l.debug("prompt-dedup", {
|
|
rankedSkills,
|
|
droppedByCap,
|
|
previouslyInjected: [...injectedSkills]
|
|
});
|
|
const result = injectSkills(rankedSkills, {
|
|
pluginRoot: PLUGIN_ROOT,
|
|
hasEnvDedup: !dedupOff,
|
|
injectedSkills,
|
|
budgetBytes: budget,
|
|
maxSkills: MAX_SKILLS,
|
|
skillMap: skills.skillMap,
|
|
logger: l,
|
|
platform: platform ?? "claude-code"
|
|
});
|
|
return {
|
|
...result,
|
|
droppedByCap: [...result.droppedByCap, ...droppedByCap],
|
|
matchedSkills: allMatched
|
|
};
|
|
}
|
|
function formatOutput(parts, matchedSkills, injectedSkills, summaryOnly, droppedByCap, droppedByBudget, promptMatchReasons, skillMap, platform = "claude-code", env) {
|
|
if (parts.length === 0) {
|
|
return formatEmptyOutput(platform, env);
|
|
}
|
|
const skillInjection = {
|
|
version: SKILL_INJECTION_VERSION,
|
|
hookEvent: "UserPromptSubmit",
|
|
matchedSkills,
|
|
injectedSkills,
|
|
summaryOnly,
|
|
droppedByBudget
|
|
};
|
|
const metaComment = `<!-- skillInjection: ${JSON.stringify(skillInjection)} -->`;
|
|
const bannerLines = ["[vercel-plugin] Best practices auto-suggested based on prompt analysis:"];
|
|
for (const skill of injectedSkills) {
|
|
const reason = promptMatchReasons?.[skill];
|
|
if (reason) {
|
|
bannerLines.push(` - "${skill}" matched: ${reason}`);
|
|
} else {
|
|
bannerLines.push(` - "${skill}"`);
|
|
}
|
|
}
|
|
const banner = bannerLines.join("\n");
|
|
const docsBlock = buildDocsBlock(injectedSkills, skillMap);
|
|
const sections = [banner];
|
|
if (docsBlock) sections.push(docsBlock);
|
|
sections.push(parts.join("\n\n"));
|
|
const additionalContext = sections.join("\n\n") + "\n" + metaComment;
|
|
if (platform === "cursor") {
|
|
const output2 = {
|
|
additional_context: additionalContext,
|
|
continue: true
|
|
};
|
|
if (env && Object.keys(env).length > 0) {
|
|
output2.env = env;
|
|
}
|
|
return JSON.stringify(output2);
|
|
}
|
|
const output = {
|
|
hookSpecificOutput: {
|
|
hookEventName: "UserPromptSubmit",
|
|
additionalContext
|
|
}
|
|
};
|
|
return JSON.stringify(output);
|
|
}
|
|
function run() {
|
|
const timing = {};
|
|
const tPhase = log.active ? log.now() : 0;
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(0, "utf-8");
|
|
} catch {
|
|
return "{}";
|
|
}
|
|
const platform = detectPromptHookPlatformFromRaw(raw);
|
|
const parsed = parsePromptInput(raw, log);
|
|
if (!parsed) return formatEmptyOutput(platform);
|
|
if (log.active) timing.stdin_parse = Math.round(log.now() - tPhase);
|
|
const { prompt, sessionId, cwd } = parsed;
|
|
const promptEnvBefore = capturePromptEnvSnapshot();
|
|
const normalizedPrompt = normalizePromptText(prompt);
|
|
if (!normalizedPrompt) {
|
|
log.debug("normalized-prompt-empty", {});
|
|
return formatEmptyOutput(platform);
|
|
}
|
|
const tSkillmap = log.active ? log.now() : 0;
|
|
const skills = loadSkills(PLUGIN_ROOT, log, cwd);
|
|
if (!skills) return formatEmptyOutput(platform);
|
|
if (log.active) timing.skillmap_load = Math.round(log.now() - tSkillmap);
|
|
const tAnalyze = log.active ? log.now() : 0;
|
|
const seenSkillState = resolvePromptSeenSkillState(sessionId, skills.skillMap);
|
|
const { dedupOff, hasFileDedup, seenState } = seenSkillState;
|
|
if (seenSkillState.compactionResetApplied) {
|
|
log.debug("dedup-compaction-reset", {
|
|
sessionId,
|
|
threshold: COMPACTION_REINJECT_MIN_PRIORITY,
|
|
clearedSkills: seenSkillState.clearedSkills
|
|
});
|
|
}
|
|
const budget = getInjectionBudget();
|
|
const lexicalEnabled = process.env.VERCEL_PLUGIN_LEXICAL_PROMPT !== "0";
|
|
if (lexicalEnabled) {
|
|
initializeLexicalIndex(new Map(Object.entries(skills.skillMap)));
|
|
}
|
|
const report = analyzePrompt(prompt, skills.skillMap, seenState, budget, MAX_SKILLS, { lexicalEnabled });
|
|
applyPromptScoreAdjustmentsToReport(report, skills.skillMap, log, {
|
|
maxSkills: MAX_SKILLS,
|
|
budgetBytes: budget
|
|
});
|
|
if (log.active) timing.analyze = Math.round(log.now() - tAnalyze);
|
|
log.trace("prompt-analysis-full", report);
|
|
for (const [skill, r] of Object.entries(report.perSkillResults)) {
|
|
log.debug("prompt-signal-eval", {
|
|
skill,
|
|
score: r.score,
|
|
reason: r.reason,
|
|
matched: r.matched,
|
|
suppressed: r.suppressed
|
|
});
|
|
}
|
|
log.debug("prompt-selection", {
|
|
selectedSkills: report.selectedSkills,
|
|
droppedByCap: report.droppedByCap,
|
|
droppedByBudget: report.droppedByBudget,
|
|
dedupStrategy: report.dedupState.strategy,
|
|
filteredByDedup: report.dedupState.filteredByDedup,
|
|
budgetBytes: report.budgetBytes,
|
|
timingMs: report.timingMs
|
|
});
|
|
const intentResult = classifyTroubleshootingIntent(normalizedPrompt);
|
|
if (intentResult.intent) {
|
|
for (const skill of intentResult.skills) {
|
|
if (!report.selectedSkills.includes(skill) && report.selectedSkills.length < MAX_SKILLS) {
|
|
report.selectedSkills.push(skill);
|
|
}
|
|
}
|
|
logDecision(log, {
|
|
hook: "UserPromptSubmit",
|
|
event: "troubleshooting_intent_routed",
|
|
intent: intentResult.intent,
|
|
skills: intentResult.skills,
|
|
reason: intentResult.reason,
|
|
durationMs: log.active ? log.elapsed() : void 0
|
|
});
|
|
} else if (intentResult.reason === "suppressed by test framework mention") {
|
|
const suppressSet = /* @__PURE__ */ new Set(["verification", "investigation-mode", "agent-browser-verify"]);
|
|
const before = report.selectedSkills.length;
|
|
report.selectedSkills = report.selectedSkills.filter((s) => !suppressSet.has(s));
|
|
if (report.selectedSkills.length < before) {
|
|
logDecision(log, {
|
|
hook: "UserPromptSubmit",
|
|
event: "verification_family_suppressed",
|
|
reason: intentResult.reason,
|
|
durationMs: log.active ? log.elapsed() : void 0
|
|
});
|
|
}
|
|
}
|
|
const investigationSkills = ["investigation-mode", "observability", "workflow"];
|
|
const matchedInvestigation = Object.entries(report.perSkillResults).filter(([skill, r]) => r.matched && investigationSkills.includes(skill));
|
|
if (matchedInvestigation.length > 0) {
|
|
logDecision(log, {
|
|
hook: "UserPromptSubmit",
|
|
event: "investigation_intent_detected",
|
|
reason: "frustration_or_debug_signals",
|
|
skills: matchedInvestigation.map(([skill, r]) => ({ skill, score: r.score })),
|
|
durationMs: log.active ? log.elapsed() : void 0
|
|
});
|
|
}
|
|
const companionResult = selectInvestigationCompanion(
|
|
report.selectedSkills,
|
|
report.perSkillResults
|
|
);
|
|
if (companionResult.companion) {
|
|
const companion = companionResult.companion;
|
|
const newSelected = ["investigation-mode"];
|
|
if (!report.selectedSkills.includes(companion)) {
|
|
newSelected.push(companion);
|
|
} else {
|
|
newSelected.push(companion);
|
|
}
|
|
report.selectedSkills.length = 0;
|
|
report.selectedSkills.push(...newSelected);
|
|
logDecision(log, {
|
|
hook: "UserPromptSubmit",
|
|
event: "companion_selected",
|
|
skill: "investigation-mode",
|
|
companion,
|
|
reason: companionResult.reason,
|
|
durationMs: log.active ? log.elapsed() : void 0
|
|
});
|
|
} else if (report.selectedSkills.length > 1) {
|
|
logDecision(log, {
|
|
hook: "UserPromptSubmit",
|
|
event: "companion_selected",
|
|
skill: report.selectedSkills[0],
|
|
companion: report.selectedSkills[1],
|
|
reason: "multi_skill_prompt_match",
|
|
durationMs: log.active ? log.elapsed() : void 0
|
|
});
|
|
}
|
|
const promptProjectFacts = parseFactSet(process.env.VERCEL_PLUGIN_PROJECT_FACTS);
|
|
if (promptProjectFacts.size === 0 && sessionId) {
|
|
const gf = readSessionFile(sessionId, "greenfield");
|
|
if (gf === "true") promptProjectFacts.add("greenfield");
|
|
}
|
|
if (promptProjectFacts.size > 0) {
|
|
const suppressedByFacts = [];
|
|
for (const [skill, r] of Object.entries(report.perSkillResults)) {
|
|
const cfg = skills.skillMap[skill];
|
|
const suppress = cfg?.suppressWhenProjectFacts;
|
|
if (suppress && suppress.some((fact) => promptProjectFacts.has(fact))) {
|
|
r.matched = false;
|
|
r.suppressed = true;
|
|
r.reason = `suppressed by project fact: ${suppress.find((f) => promptProjectFacts.has(f))}`;
|
|
suppressedByFacts.push(skill);
|
|
}
|
|
}
|
|
if (suppressedByFacts.length > 0) {
|
|
report.selectedSkills = report.selectedSkills.filter((s) => !suppressedByFacts.includes(s));
|
|
log.debug("suppress-by-project-facts", { suppressed: suppressedByFacts, facts: [...promptProjectFacts] });
|
|
}
|
|
}
|
|
const allMatchedPromptSkills = Object.entries(report.perSkillResults).filter(([, r]) => r.matched).map(([skill]) => skill);
|
|
const promptCoInject = applyCoInjectRules({
|
|
rankedSkills: report.selectedSkills,
|
|
allMatchedSkills: allMatchedPromptSkills,
|
|
skillMap: skills.skillMap,
|
|
projectFacts: parseFactSet(process.env.VERCEL_PLUGIN_PROJECT_FACTS),
|
|
runtimeFacts: /* @__PURE__ */ new Set(),
|
|
injectedSkills: dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState),
|
|
maxSkills: MAX_SKILLS,
|
|
dedupOff,
|
|
logger: log
|
|
});
|
|
report.selectedSkills.length = 0;
|
|
report.selectedSkills.push(...promptCoInject.rankedSkills);
|
|
const allMatched = Object.entries(report.perSkillResults).filter(([, r]) => r.matched).map(([skill]) => skill);
|
|
const isGreenfield = sessionId ? readSessionFile(sessionId, "greenfield") === "true" : false;
|
|
const hasExplicitPromptMatch = Object.entries(report.perSkillResults).some(
|
|
([, r]) => r.matched && r.reason && !r.reason.includes("lexical")
|
|
);
|
|
if (isGreenfield && !hasExplicitPromptMatch) {
|
|
log.complete("greenfield_no_vercel_signals", { matchedCount: allMatched.length }, log.active ? timing : null);
|
|
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
|
|
}
|
|
if (isGreenfield && hasExplicitPromptMatch && cwd && sessionId) {
|
|
const alreadyRecommended = readSessionFile(sessionId, "skills-recommended");
|
|
if (!alreadyRecommended) {
|
|
writeSessionFile(sessionId, "skills-recommended", "true");
|
|
const registryMeta = loadRegistrySkillMetadata();
|
|
const bundles = [
|
|
{
|
|
name: "AI App",
|
|
description: "AI SDK, Chat SDK, AI Elements, Workflow \u2014 AI-powered features with automatic auth via Vercel AI Gateway",
|
|
skills: []
|
|
},
|
|
{
|
|
name: "Web App",
|
|
description: "Next.js best practices, React patterns, caching, shadcn/ui components",
|
|
skills: []
|
|
},
|
|
{
|
|
name: "Backend / API",
|
|
description: "Vercel CLI, deployments, feature flags, turborepo",
|
|
skills: []
|
|
}
|
|
];
|
|
const bundleMap = {
|
|
"ai-sdk": 0,
|
|
"ai-gateway": 0,
|
|
"ai-elements": 0,
|
|
"chat-sdk": 0,
|
|
"workflow": 0,
|
|
"nextjs": 1,
|
|
"next-cache-components": 1,
|
|
"next-upgrade": 1,
|
|
"next-forge": 1,
|
|
"react-best-practices": 1,
|
|
"shadcn": 1,
|
|
"vercel-cli": 2,
|
|
"deployments-cicd": 2,
|
|
"turborepo": 2,
|
|
"vercel-flags": 2,
|
|
"vercel-sandbox": 2,
|
|
"agent-browser": 2
|
|
};
|
|
for (const [name] of Object.entries(skills.skillMap)) {
|
|
const meta = registryMeta.get(name);
|
|
if (!meta?.registry) continue;
|
|
const bundleIdx = bundleMap[name];
|
|
if (bundleIdx === void 0) continue;
|
|
bundles[bundleIdx].skills.push({
|
|
name,
|
|
registry: meta.registry,
|
|
installSlug: meta.registrySlug ?? name
|
|
});
|
|
}
|
|
const bundleLines = bundles.filter((b) => b.skills.length > 0).map((b, i) => {
|
|
const skillNames = b.skills.map((s) => s.name).join(", ");
|
|
return `${i + 1}. **${b.name}** \u2014 ${b.description}
|
|
Skills: ${skillNames}`;
|
|
}).join("\n");
|
|
const bundleCommands = bundles.filter((b) => b.skills.length > 0).map((b, i) => {
|
|
const groups = /* @__PURE__ */ new Map();
|
|
for (const s of b.skills) {
|
|
const group = groups.get(s.registry) ?? [];
|
|
group.push(s.installSlug);
|
|
groups.set(s.registry, group);
|
|
}
|
|
const cmds = [];
|
|
for (const [registry, slugs] of groups) {
|
|
const cmd = buildSkillsAddCommand(registry, slugs, "claude-code");
|
|
if (cmd) cmds.push(cmd.printable);
|
|
}
|
|
return `Starter ${i + 1} (${b.name}): ${cmds.join(" && ")}`;
|
|
}).join("\n");
|
|
const nonInteractive = process.env.VERCEL_PLUGIN_NONINTERACTIVE === "1";
|
|
const matchedSkillNames = new Set(allMatched);
|
|
const scaffoldingLines = [];
|
|
if (matchedSkillNames.has("nextjs") || matchedSkillNames.has("next-cache-components")) {
|
|
scaffoldingLines.push(
|
|
"**Scaffold first**: Run `npx create-next-app@latest .` (with flags like `--ts --tailwind --eslint --app --src-dir`) to initialize the project.",
|
|
"This ensures the latest Next.js version with correct deps. Do NOT manually write package.json or config files."
|
|
);
|
|
}
|
|
if (matchedSkillNames.has("ai-sdk")) {
|
|
scaffoldingLines.push(
|
|
"**AI SDK**: After scaffolding, add the AI SDK with `npm install ai @ai-sdk/openai` (or the relevant provider package)."
|
|
);
|
|
}
|
|
const scaffoldingBlock = scaffoldingLines.length > 0 ? `### Project Scaffolding
|
|
${scaffoldingLines.join("\n")}
|
|
|
|
` : "";
|
|
const setupNote = `
|
|
RECOMMENDED SETUP: After installing skills, run \`vercel link\` to connect to a Vercel project, then \`vercel env pull\` to get OIDC credentials. This enables AI Gateway routing with automatic auth \u2014 no API keys needed.
|
|
`;
|
|
const discoveryPrompt = nonInteractive ? `Vercel plugin is active.
|
|
|
|
${scaffoldingBlock}Recommended starters for this task:
|
|
|
|
${bundleLines}
|
|
|
|
Install commands:
|
|
${bundleCommands}
|
|
|
|
Install the most relevant starters from the project root before writing code.
|
|
After installing, skill guidance will be loaded automatically.
|
|
${setupNote}` : `Vercel plugin is active.
|
|
|
|
${scaffoldingBlock}I can preload relevant guidance for this project.
|
|
|
|
Recommended starters:
|
|
${bundleLines}
|
|
|
|
Ask the user: "Which starters would you like? Type the numbers (e.g. 1,2), 'all', or just keep going and I'll stay lightweight."
|
|
|
|
Install commands:
|
|
${bundleCommands}
|
|
|
|
After the user picks, run the install commands from the project root.
|
|
${setupNote}`;
|
|
const output = {
|
|
hookSpecificOutput: {
|
|
hookEventName: "UserPromptSubmit",
|
|
additionalContext: discoveryPrompt
|
|
}
|
|
};
|
|
return JSON.stringify(output);
|
|
}
|
|
}
|
|
function buildInstallCommandForSlugs(slugs) {
|
|
const meta = loadRegistrySkillMetadata();
|
|
const groups = /* @__PURE__ */ new Map();
|
|
for (const slug of slugs) {
|
|
const m = meta.get(slug);
|
|
if (!m?.registry) continue;
|
|
const installName = m.registrySlug ?? slug;
|
|
const group = groups.get(m.registry) ?? [];
|
|
group.push(installName);
|
|
groups.set(m.registry, group);
|
|
}
|
|
if (groups.size === 0) return null;
|
|
const commands = [];
|
|
for (const [registry, installNames] of groups) {
|
|
const cmd = buildSkillsAddCommand(registry, installNames, "claude-code");
|
|
if (cmd) commands.push(cmd.printable);
|
|
}
|
|
return commands.join(" && ");
|
|
}
|
|
if (!isGreenfield && hasExplicitPromptMatch && cwd && sessionId && process.env.VERCEL_PLUGIN_SYNC_INSTALL !== "0") {
|
|
const registryMeta = loadRegistrySkillMetadata();
|
|
const installedSkills = skills.skillStore?.listInstalledSkills() ?? [];
|
|
const installedSet = new Set(installedSkills);
|
|
const recommendable = allMatched.filter(
|
|
(s) => registryMeta.has(s) && !installedSet.has(s)
|
|
);
|
|
if (recommendable.length > 0) {
|
|
const alreadyRecommended = readSessionFile(sessionId, "skills-recommended");
|
|
if (!alreadyRecommended) {
|
|
writeSessionFile(sessionId, "skills-recommended", recommendable.join(","));
|
|
const nonInteractive = process.env.VERCEL_PLUGIN_NONINTERACTIVE === "1";
|
|
if (nonInteractive) {
|
|
const groups = /* @__PURE__ */ new Map();
|
|
for (const slug of recommendable) {
|
|
const meta = registryMeta.get(slug);
|
|
if (!meta?.registry) continue;
|
|
const installName = meta.registrySlug ?? slug;
|
|
const group = groups.get(meta.registry) ?? [];
|
|
group.push(installName);
|
|
groups.set(meta.registry, group);
|
|
}
|
|
for (const [registry, installNames] of groups) {
|
|
const cmd = buildSkillsAddCommand(registry, installNames, "claude-code");
|
|
if (!cmd) continue;
|
|
try {
|
|
log.debug("prompt-skill-auto-install-start", { registry, skills: installNames, cwd });
|
|
execFileSync(cmd.file, cmd.args, {
|
|
cwd,
|
|
timeout: 3e4,
|
|
env: { ...process.env, CI: "1" },
|
|
maxBuffer: 1024 * 1024,
|
|
stdio: "pipe"
|
|
});
|
|
log.debug("prompt-skill-auto-install-success", { registry, skills: installNames });
|
|
} catch (error) {
|
|
log.debug("prompt-skill-auto-install-failed", { registry, error: String(error) });
|
|
}
|
|
}
|
|
} else {
|
|
const skillList = recommendable.map((s, i) => {
|
|
const meta = registryMeta.get(s);
|
|
const desc = skills.skillMap[s]?.summary || "";
|
|
return `${i + 1}. **${s}**${desc ? ` \u2014 ${desc}` : ""}`;
|
|
}).join("\n");
|
|
const installCmd = buildInstallCommandForSlugs(recommendable);
|
|
const recommendPrompt = `<CRITICAL>
|
|
I detected Vercel skills that match your request. Before proceeding, ask the user:
|
|
|
|
"I found ${recommendable.length} relevant Vercel skill${recommendable.length > 1 ? "s" : ""} for this task:
|
|
|
|
${skillList}
|
|
|
|
Would you like me to install them? (yes for all, or pick by number)"
|
|
|
|
If they agree, run from the project root:
|
|
${installCmd}
|
|
Do not skip this step.
|
|
</CRITICAL>`;
|
|
const output = {
|
|
hookSpecificOutput: {
|
|
hookEventName: "UserPromptSubmit",
|
|
additionalContext: recommendPrompt
|
|
}
|
|
};
|
|
return JSON.stringify(output);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (allMatched.length === 0) {
|
|
log.debug("prompt-analysis-issue", {
|
|
issue: "no_prompt_matches",
|
|
evaluatedSkills: Object.keys(report.perSkillResults),
|
|
suppressedSkills: Object.entries(report.perSkillResults).filter(([, r]) => r.suppressed).map(([skill]) => skill)
|
|
});
|
|
log.complete("no_prompt_matches", { matchedCount: 0 }, log.active ? timing : null);
|
|
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
|
|
}
|
|
if (report.selectedSkills.length === 0) {
|
|
log.debug("prompt-analysis-issue", {
|
|
issue: "all_deduped",
|
|
matchedSkills: allMatched,
|
|
seenSkills: report.dedupState.seenSkills,
|
|
dedupStrategy: report.dedupState.strategy
|
|
});
|
|
log.complete("all_deduped", {
|
|
matchedCount: allMatched.length,
|
|
dedupedCount: allMatched.length
|
|
}, log.active ? timing : null);
|
|
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
|
|
}
|
|
const tInject = log.active ? log.now() : 0;
|
|
const injectedSkills = dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState);
|
|
const injectResult = injectSkills(report.selectedSkills, {
|
|
pluginRoot: PLUGIN_ROOT,
|
|
projectRoot: cwd,
|
|
skillStore: skills.skillStore,
|
|
hasEnvDedup: !dedupOff,
|
|
sessionId,
|
|
injectedSkills,
|
|
budgetBytes: budget,
|
|
maxSkills: MAX_SKILLS,
|
|
skillMap: skills.skillMap,
|
|
logger: log,
|
|
platform
|
|
});
|
|
if (log.active) timing.inject = Math.round(log.now() - tInject);
|
|
const { parts, loaded, summaryOnly, manifestSummaryOnly } = injectResult;
|
|
if (manifestSummaryOnly && manifestSummaryOnly.length > 0 && sessionId && cwd) {
|
|
const onDemandResult = triggerOnDemandInstall({
|
|
summaryOnlySkills: manifestSummaryOnly,
|
|
sessionId,
|
|
projectRoot: cwd,
|
|
pluginRoot: PLUGIN_ROOT,
|
|
logger: log
|
|
});
|
|
if (onDemandResult.triggered.length > 0) {
|
|
log.debug("on-demand-install-triggered", {
|
|
triggered: onDemandResult.triggered,
|
|
alreadyAttempted: onDemandResult.alreadyAttempted,
|
|
noRegistry: onDemandResult.noRegistry
|
|
});
|
|
}
|
|
}
|
|
let syncedSeenSkills = seenState;
|
|
if (hasFileDedup) {
|
|
syncedSeenSkills = syncPromptSeenSkillClaims(sessionId, loaded);
|
|
}
|
|
const droppedByCap = [...injectResult.droppedByCap, ...report.droppedByCap];
|
|
const droppedByBudget = [...injectResult.droppedByBudget, ...report.droppedByBudget];
|
|
const matchedSkills = allMatched;
|
|
if (parts.length === 0) {
|
|
log.complete("all_deduped", {
|
|
matchedCount: matchedSkills.length,
|
|
dedupedCount: matchedSkills.length
|
|
}, log.active ? timing : null);
|
|
return formatEmptyOutput(platform);
|
|
}
|
|
if (log.active) timing.total = log.elapsed();
|
|
log.complete("injected", {
|
|
matchedCount: matchedSkills.length,
|
|
injectedCount: loaded.length,
|
|
dedupedCount: matchedSkills.length - loaded.length - droppedByCap.length - droppedByBudget.length,
|
|
cappedCount: droppedByCap.length + droppedByBudget.length
|
|
}, log.active ? timing : null);
|
|
if (loaded.length > 0) {
|
|
appendAuditLog({
|
|
event: "prompt-skill-injection",
|
|
hookEvent: "UserPromptSubmit",
|
|
matchedSkills,
|
|
injectedSkills: loaded,
|
|
summaryOnly,
|
|
droppedByCap,
|
|
droppedByBudget
|
|
}, cwd);
|
|
}
|
|
if (sessionId && loaded.length > 0) {
|
|
const telemetryEntries = [];
|
|
for (const skill of loaded) {
|
|
const r = report.perSkillResults[skill];
|
|
telemetryEntries.push(
|
|
{ key: "prompt:skill", value: skill },
|
|
{ key: "prompt:score", value: String(r?.score ?? 0) },
|
|
{ key: "prompt:hook", value: "UserPromptSubmit" }
|
|
);
|
|
}
|
|
trackBaseEvents(sessionId, telemetryEntries).catch(() => {
|
|
});
|
|
}
|
|
let outputEnv;
|
|
const envFile = nonEmptyString(process.env.CLAUDE_ENV_FILE);
|
|
const seenSkills = hasFileDedup ? syncedSeenSkills : seenState;
|
|
if (platform === "cursor") {
|
|
if (!envFile) {
|
|
process.env[ENV_SEEN_SKILLS_KEY] = seenSkills;
|
|
}
|
|
outputEnv = finalizePromptEnvUpdates(platform, promptEnvBefore);
|
|
}
|
|
const promptMatchReasons = {};
|
|
for (const skill of loaded) {
|
|
const r = report.perSkillResults[skill];
|
|
if (r?.reason) {
|
|
promptMatchReasons[skill] = r.reason;
|
|
}
|
|
}
|
|
return formatOutput(
|
|
parts,
|
|
matchedSkills,
|
|
loaded,
|
|
summaryOnly,
|
|
droppedByCap,
|
|
droppedByBudget,
|
|
promptMatchReasons,
|
|
skills.skillMap,
|
|
platform,
|
|
outputEnv
|
|
);
|
|
}
|
|
function isMainModule() {
|
|
try {
|
|
const scriptPath = realpathSync(resolve(process.argv[1] || ""));
|
|
const modulePath = realpathSync(fileURLToPath(import.meta.url));
|
|
return scriptPath === modulePath;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (isMainModule()) {
|
|
try {
|
|
const output = run();
|
|
process.stdout.write(output);
|
|
} catch (err) {
|
|
const entry = [
|
|
`[${(/* @__PURE__ */ new Date()).toISOString()}] CRASH in user-prompt-submit-skill-inject.mts`,
|
|
` error: ${err?.message || String(err)}`,
|
|
` stack: ${err?.stack || "(no stack)"}`,
|
|
` PLUGIN_ROOT: ${PLUGIN_ROOT}`,
|
|
` argv: ${JSON.stringify(process.argv)}`,
|
|
` cwd: ${process.cwd()}`,
|
|
""
|
|
].join("\n");
|
|
process.stderr.write(entry);
|
|
process.stdout.write("{}");
|
|
}
|
|
}
|
|
export {
|
|
INVESTIGATION_COMPANION_SKILLS,
|
|
deduplicateAndInject,
|
|
formatOutput,
|
|
matchPromptSignals,
|
|
parsePromptInput,
|
|
resolvePromptSeenSkillState,
|
|
run,
|
|
selectInvestigationCompanion,
|
|
syncPromptSeenSkillClaims
|
|
};
|