Files
vercel__vercel-plugin/hooks/prompt-analysis.mjs
John Lindquist 8e24d0694a feat: add chainTo skill chaining, prompt signals, validation upgrades, and dedup reset on context clear
Add a chainTo frontmatter field to skills that triggers follow-up skill injection
when PostToolUse file contents match regex patterns. Add upgradeToSkill/upgradeWhy
fields to validation rules so validate errors can recommend loading a specific skill.
Register posttooluse-bash-chain.mjs in hooks.json. Add ChainToRule interface to
patterns.mts and skill-map-frontmatter.mts, with parseChainToRules() parser. Add
duplicate-key detection to the YAML parser. Reset dedup claim dir and session file
on clear/compact events in session-start-seen-skills so skills re-inject into fresh
context windows.

Add chainTo rules to: agent-browser-verify, agent-browser, ai-elements, ai-gateway,
ai-generation-persistence, ai-sdk, auth, bootstrap, chat-sdk, cms, cron-jobs,
deployments-cicd, email, env-vars, geist, investigation-mode, json-render,
marketplace, micro, ncc, next-forge, nextjs, observability, payments,
react-best-practices, routing-middleware, runtime-cache, satori, shadcn,
sign-in-with-vercel, swr, turbopack, turborepo, v0-dev, vercel-agent, vercel-api,
vercel-cli, vercel-firewall, vercel-flags, vercel-functions, vercel-queues,
vercel-sandbox, vercel-storage, verification, workflow. Add upgradeToSkill to
ai-elements and ai-sdk validate rules. Expand ai-sdk validate messages with
Run Skill() hints. Update nextjs, vercel-storage, runtime-cache, workflow, turborepo
skill bodies.

Add new skills: geistdocs (Geist design system docs), zzz-test-meta-name-mask
(test fixture). Add skills/_chain-audit.md chain coverage audit doc.

Delete .claude-plugin/marketplace.json, .claude-plugin/plugin.json (deprecated),
skills/edge-runtime/SKILL.md (consolidated into vercel-functions).

Add tests: posttooluse-chain.test.ts (4699 lines, chain injection e2e),
ai-sdk-companion.test.ts (181 lines). Expand build-skill-map.test.ts (+335 lines),
validate-rules.test.ts (+936 lines), session-start-seen-skills.test.ts (+74 lines),
skill-map-frontmatter.test.ts (+50 lines), verification-skill.test.ts (+20 lines).

Update build-manifest.ts to emit chainTo rules and upgradeToSkill fields. Rebuild
generated/skill-manifest.json, generated/skill-catalog.md, generated/build-from-skills.manifest.json.
Rebuild all compiled hooks/*.mjs. Update CLAUDE.md lexical prompt default to on.
Update vercel.md ecosystem graph, docs, and cli-reference.
2026-03-11 15:18:35 -06:00

143 lines
5.4 KiB
JavaScript

// hooks/src/prompt-analysis.mts
import { normalizePromptText, compilePromptSignals, matchPromptWithReason, scorePromptWithLexical } from "./prompt-patterns.mjs";
import { searchSkills } from "./lexical-index.mjs";
import { parseSeenSkills } from "./patterns.mjs";
function analyzePrompt(prompt, skillMap, seenSkills, budgetBytes, maxSkills, options) {
const t0 = performance.now();
const lexicalEnabled = options?.lexicalEnabled ?? false;
const normalizedPrompt = normalizePromptText(prompt);
const dedupOff = process.env.VERCEL_PLUGIN_HOOK_DEDUP === "off";
const hasEnvVar = typeof seenSkills === "string";
const strategy = dedupOff ? "disabled" : hasEnvVar ? "env-var" : "memory-only";
const seenSet = dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenSkills);
const lexicalHits = lexicalEnabled ? searchSkills(prompt) : [];
const lexicalScoreMap = new Map(lexicalHits.map((h) => [h.skill, h.score]));
const LEXICAL_BOOST_CAP = 4;
const RETRIEVAL_LEXICAL_BOOST_CAP = 8;
const RETRIEVAL_TOP_K = 5;
const topKLexicalSkills = new Set(
lexicalHits.slice(0, RETRIEVAL_TOP_K).map((h) => h.skill)
);
const perSkillResults = {};
const matched = [];
for (const [skill, config] of Object.entries(skillMap)) {
const hasPromptSignals = !!config.promptSignals;
if (!hasPromptSignals && !lexicalEnabled) continue;
if (!hasPromptSignals && !config.retrieval) continue;
const compiled = hasPromptSignals ? compilePromptSignals(config.promptSignals) : void 0;
if (lexicalEnabled) {
const exactResult = compiled ? matchPromptWithReason(normalizedPrompt, compiled) : { matched: false, score: 0, reason: "no promptSignals" };
if (exactResult.score === -Infinity) {
perSkillResults[skill] = {
score: -Infinity,
reason: exactResult.reason,
matched: false,
suppressed: true
};
continue;
}
const minScore = compiled?.minScore ?? 6;
const rawLexical = lexicalScoreMap.get(skill) ?? 0;
if (exactResult.matched) {
perSkillResults[skill] = {
score: exactResult.score,
reason: exactResult.reason,
matched: true,
suppressed: false
};
matched.push({ skill, score: exactResult.score, priority: config.priority });
} else if (rawLexical > 0 && (exactResult.score > 0 || !hasPromptSignals || !!config.retrieval && topKLexicalSkills.has(skill))) {
const lexResult = scorePromptWithLexical(prompt, skill, compiled, lexicalHits);
const isRetrievalRecall = !!config.retrieval && topKLexicalSkills.has(skill) && exactResult.score <= 0;
const boostCap = isRetrievalRecall ? RETRIEVAL_LEXICAL_BOOST_CAP : LEXICAL_BOOST_CAP;
const lexicalBoost = Math.min(
Math.max(lexResult.score - exactResult.score, 0),
boostCap
);
const effectiveScore = exactResult.score + lexicalBoost;
const isMatched = effectiveScore >= minScore;
const parts = [];
if (exactResult.score > 0) parts.push(exactResult.reason);
parts.push(
`lexical ${isMatched ? "recall" : "boost"} (raw ${rawLexical.toFixed(1)}, capped +${lexicalBoost.toFixed(1)}, source: ${lexResult.source})`
);
const reason = parts.join("; ");
perSkillResults[skill] = {
score: effectiveScore,
reason,
matched: isMatched,
suppressed: false
};
if (isMatched) {
matched.push({ skill, score: effectiveScore, priority: config.priority });
}
} else {
perSkillResults[skill] = {
score: exactResult.score,
reason: exactResult.reason,
matched: false,
suppressed: false
};
}
} else {
const result = matchPromptWithReason(normalizedPrompt, compiled);
perSkillResults[skill] = {
score: result.score,
reason: result.reason,
matched: result.matched,
suppressed: result.score === -Infinity
};
if (result.matched) {
matched.push({ skill, score: result.score, priority: config.priority });
}
}
}
matched.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);
});
const filteredByDedup = [];
const afterDedup = matched.filter((m) => {
if (!dedupOff && seenSet.has(m.skill)) {
filteredByDedup.push(m.skill);
return false;
}
return true;
});
const selected = afterDedup.slice(0, maxSkills);
const droppedByCap = afterDedup.slice(maxSkills).map((m) => m.skill);
const selectedSkills = selected.map((m) => m.skill);
const droppedByBudget = [];
let usedBytes = 0;
const finalSelected = [];
for (const skill of selectedSkills) {
const config = skillMap[skill];
const estimatedSize = config?.summary ? Math.max(config.summary.length * 10, 500) : 500;
if (usedBytes + estimatedSize > budgetBytes && finalSelected.length > 0) {
droppedByBudget.push(skill);
} else {
usedBytes += estimatedSize;
finalSelected.push(skill);
}
}
const timingMs = Math.round(performance.now() - t0);
return {
normalizedPrompt,
perSkillResults,
selectedSkills: finalSelected,
droppedByCap,
droppedByBudget,
dedupState: {
strategy,
seenSkills: [...seenSet],
filteredByDedup
},
budgetBytes,
timingMs
};
}
export {
analyzePrompt
};