reduce-vercel-injection-at-start

This commit is contained in:
melkeydev
2026-04-01 15:57:07 -07:00
parent 44417d3786
commit 8449a2a561
14 changed files with 644 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"generatedAt": "2026-04-01T17:44:37.555Z",
"generatedAt": "2026-04-01T19:46:10.445Z",
"templates": [
{
"template": "agents/ai-architect.md.tmpl",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-04-01T17:44:37.524Z",
"generatedAt": "2026-04-01T19:46:11.138Z",
"version": 2,
"skills": {
"vercel-agent": {
+2 -1
View File
@@ -55,9 +55,10 @@ function stripFrontmatter(content) {
function main() {
const input = parseInjectClaudeMdInput(readFileSync(0, "utf8"));
const platform = detectInjectClaudeMdPlatform(input);
const thinSessionContext = safeReadFile(join(pluginRoot(), "vercel-session.md"));
const knowledgeUpdateRaw = safeReadFile(join(pluginRoot(), "skills", "knowledge-update", "SKILL.md"));
const knowledgeUpdate = knowledgeUpdateRaw !== null ? stripFrontmatter(knowledgeUpdateRaw) : null;
const parts = buildInjectClaudeMdParts(safeReadFile(join(pluginRoot(), "vercel.md")), process.env, knowledgeUpdate);
const parts = buildInjectClaudeMdParts(thinSessionContext, process.env, knowledgeUpdate);
if (parts.length === 0) {
return;
}
+21
View File
@@ -34,6 +34,7 @@ import {
import { resolveVercelJsonSkills, isVercelJsonPath, VERCEL_JSON_SKILLS } from "./vercel-config.mjs";
import { createLogger, logDecision } from "./logger.mjs";
import { trackBaseEvents } from "./telemetry.mjs";
import { selectManagedContextChunk } from "./vercel-context.mjs";
var MAX_SKILLS = 3;
var DEFAULT_INJECTION_BUDGET_BYTES = 18e3;
var SETUP_MODE_BOOTSTRAP_SKILL = "bootstrap";
@@ -553,6 +554,7 @@ function formatOutput({
parts,
matched,
injectedSkills,
contextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -573,6 +575,7 @@ function formatOutput({
toolTarget: toolName === "Bash" ? redactCommand(toolTarget) : toolTarget,
matchedSkills: [...matched],
injectedSkills,
contextChunks: contextChunks || [],
summaryOnly: summaryOnly || [],
droppedByBudget: droppedByBudget || []
};
@@ -722,6 +725,22 @@ function run() {
parts.push(VERCEL_ENV_HELP);
log.debug("vercel-env-help-appended", { subcommand: vercelEnvHelp.subcommand || "" });
}
const injectedContextChunks = [];
if (!scopeId) {
const chunk = selectManagedContextChunk(loaded, {
pluginRoot: PLUGIN_ROOT,
sessionId
});
if (chunk) {
parts.push(chunk.wrapped);
injectedContextChunks.push(chunk.chunkId);
log.debug("managed-context-chunk-injected", {
chunkId: chunk.chunkId,
skill: chunk.skill,
bytes: chunk.bytes
});
}
}
if (parts.length === 0) {
if (log.active) timing.total = log.elapsed();
log.complete("no_matches", {
@@ -764,6 +783,7 @@ function run() {
parts,
matched,
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -782,6 +802,7 @@ function run() {
toolTarget: toolName === "Bash" ? redactCommand(toolTarget) : toolTarget,
matchedSkills: [...matched],
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget
+3 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* SessionStart hook: inject vercel.md as additional context.
* SessionStart hook: inject a thin Vercel session context.
* Claude Code receives plain-text stdout.
* Cursor receives `{ additional_context: "..." }` JSON on stdout.
*/
@@ -86,9 +86,10 @@ function stripFrontmatter(content: string): string {
function main(): void {
const input = parseInjectClaudeMdInput(readFileSync(0, "utf8"));
const platform = detectInjectClaudeMdPlatform(input);
const thinSessionContext = safeReadFile(join(pluginRoot(), "vercel-session.md"));
const knowledgeUpdateRaw = safeReadFile(join(pluginRoot(), "skills", "knowledge-update", "SKILL.md"));
const knowledgeUpdate = knowledgeUpdateRaw !== null ? stripFrontmatter(knowledgeUpdateRaw) : null;
const parts = buildInjectClaudeMdParts(safeReadFile(join(pluginRoot(), "vercel.md")), process.env, knowledgeUpdate);
const parts = buildInjectClaudeMdParts(thinSessionContext, process.env, knowledgeUpdate);
if (parts.length === 0) {
return;
+23
View File
@@ -59,6 +59,7 @@ import type { VercelJsonRouting } from "./vercel-config.mjs";
import { createLogger, logDecision } from "./logger.mjs";
import type { Logger } from "./logger.mjs";
import { trackBaseEvents } from "./telemetry.mjs";
import { selectManagedContextChunk } from "./vercel-context.mjs";
const MAX_SKILLS = 3;
const DEFAULT_INJECTION_BUDGET_BYTES = 18_000;
@@ -815,6 +816,7 @@ export interface FormatOutputParams {
parts: string[];
matched: Set<string>;
injectedSkills: string[];
contextChunks?: string[];
summaryOnly?: string[];
droppedByCap: string[];
droppedByBudget?: string[];
@@ -895,6 +897,7 @@ export function formatOutput({
parts,
matched,
injectedSkills,
contextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -916,6 +919,7 @@ export function formatOutput({
toolTarget: toolName === "Bash" ? redactCommand(toolTarget) : toolTarget,
matchedSkills: [...matched],
injectedSkills,
contextChunks: contextChunks || [],
summaryOnly: summaryOnly || [],
droppedByBudget: droppedByBudget || [],
};
@@ -1107,6 +1111,23 @@ function run(): string {
log.debug("vercel-env-help-appended", { subcommand: vercelEnvHelp.subcommand || "" });
}
const injectedContextChunks: string[] = [];
if (!scopeId) {
const chunk = selectManagedContextChunk(loaded, {
pluginRoot: PLUGIN_ROOT,
sessionId,
});
if (chunk) {
parts.push(chunk.wrapped);
injectedContextChunks.push(chunk.chunkId);
log.debug("managed-context-chunk-injected", {
chunkId: chunk.chunkId,
skill: chunk.skill,
bytes: chunk.bytes,
});
}
}
if (parts.length === 0) {
if (log.active) timing.total = log.elapsed();
log.complete("no_matches", {
@@ -1155,6 +1176,7 @@ function run(): string {
parts,
matched,
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -1174,6 +1196,7 @@ function run(): string {
toolTarget: toolName === "Bash" ? redactCommand(toolTarget) : toolTarget,
matchedSkills: [...matched],
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -48,6 +48,7 @@ import type { PromptAnalysisReport } from "./prompt-analysis.mjs";
import { createLogger, logDecision } from "./logger.mjs";
import type { Logger } from "./logger.mjs";
import { trackBaseEvents } from "./telemetry.mjs";
import { selectManagedContextChunk } from "./vercel-context.mjs";
const MAX_SKILLS = 2;
const DEFAULT_INJECTION_BUDGET_BYTES = 8_000;
@@ -750,6 +751,7 @@ export function formatOutput(
parts: string[],
matchedSkills: string[],
injectedSkills: string[],
contextChunks: string[],
summaryOnly: string[],
droppedByCap: string[],
droppedByBudget: string[],
@@ -767,6 +769,7 @@ export function formatOutput(
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills,
contextChunks,
summaryOnly,
droppedByBudget,
};
@@ -997,6 +1000,20 @@ export function run(): string {
if (log.active) timing.inject = Math.round(log.now() - tInject);
const { parts, loaded, summaryOnly } = injectResult;
const injectedContextChunks: string[] = [];
const chunk = selectManagedContextChunk(loaded, {
pluginRoot: PLUGIN_ROOT,
sessionId,
});
if (chunk) {
parts.push(chunk.wrapped);
injectedContextChunks.push(chunk.chunkId);
log.debug("managed-context-chunk-injected", {
chunkId: chunk.chunkId,
skill: chunk.skill,
bytes: chunk.bytes,
});
}
let syncedSeenSkills = seenState;
if (hasFileDedup) {
syncedSeenSkills = syncPromptSeenSkillClaims(sessionId as string, loaded);
@@ -1028,6 +1045,7 @@ export function run(): string {
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
@@ -1070,6 +1088,7 @@ export function run(): string {
parts,
matchedSkills,
loaded,
injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
+149
View File
@@ -0,0 +1,149 @@
import { join } from "node:path";
import {
pluginRoot as resolvePluginRoot,
safeReadFile,
syncSessionFileFromClaims,
tryClaimSessionKey,
} from "./hook-env.mjs";
const PLUGIN_ROOT = resolvePluginRoot();
const DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES = 1_800;
const CONTEXT_CHUNK_KIND = "seen-context-chunks";
interface ChunkSectionMapping {
chunkId: string;
heading: string;
}
export interface ManagedContextChunk {
chunkId: string;
heading: string;
skill: string;
content: string;
wrapped: string;
bytes: number;
}
interface ManagedContextChunkOptions {
pluginRoot?: string;
sessionId?: string | null;
budgetBytes?: number;
}
const SKILL_TO_CHUNK: Record<string, ChunkSectionMapping> = {
"nextjs": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-cache-components": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-upgrade": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"turbopack": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-forge": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"ai-sdk": { chunkId: "ai-stack", heading: "AI Stack" },
"ai-gateway": { chunkId: "ai-stack", heading: "AI Stack" },
"chat-sdk": { chunkId: "ai-stack", heading: "AI Stack" },
"vercel-functions": { chunkId: "compute-routing", heading: "Compute and Routing" },
"routing-middleware": { chunkId: "compute-routing", heading: "Compute and Routing" },
"runtime-cache": { chunkId: "compute-routing", heading: "Compute and Routing" },
"vercel-sandbox": { chunkId: "compute-routing", heading: "Compute and Routing" },
"vercel-cli": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"deployments-cicd": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"env-vars": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"marketplace": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"vercel-storage": { chunkId: "storage-data", heading: "Storage and Data" },
"workflow": { chunkId: "workflow-durable", heading: "Workflow and Durability" },
};
function parseHeadingSpec(spec: string): { level: number | null; text: string } {
const match = spec.match(/^(#{1,6})\s+(.+)$/);
if (match) {
return { level: match[1].length, text: match[2].trim().toLowerCase() };
}
return { level: null, text: spec.trim().toLowerCase() };
}
function extractDirectSection(markdown: string, headingSpec: string): string {
const { level: specLevel, text: specText } = parseHeadingSpec(headingSpec);
const lines = markdown.split("\n");
let startLine = -1;
let headingLevel = 0;
for (let i = 0; i < lines.length; i += 1) {
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
if (!headingMatch) continue;
const lineLevel = headingMatch[1].length;
const lineText = headingMatch[2].trim().toLowerCase();
if (lineText === specText && (specLevel === null || lineLevel === specLevel)) {
startLine = i;
headingLevel = lineLevel;
break;
}
}
if (startLine === -1) return "";
const contentLines: string[] = [];
for (let i = startLine + 1; i < lines.length; i += 1) {
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
if (headingMatch && headingMatch[1].length <= headingLevel) {
break;
}
contentLines.push(lines[i]);
}
return contentLines.join("\n").trim();
}
export function getManagedContextChunkForSkill(
skill: string,
options?: ManagedContextChunkOptions,
): ManagedContextChunk | null {
const mapping = SKILL_TO_CHUNK[skill];
if (!mapping) return null;
const root = options?.pluginRoot ?? PLUGIN_ROOT;
const raw = safeReadFile(join(root, "vercel.md"));
if (raw === null) return null;
const content = extractDirectSection(raw, mapping.heading);
if (!content) return null;
const wrapped = `<!-- vercel-context-chunk:${mapping.chunkId} -->\n${content}\n<!-- /vercel-context-chunk:${mapping.chunkId} -->`;
const bytes = Buffer.byteLength(wrapped, "utf8");
const budget = options?.budgetBytes ?? DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES;
if (bytes > budget) return null;
return {
chunkId: mapping.chunkId,
heading: mapping.heading,
skill,
content,
wrapped,
bytes,
};
}
export function claimManagedContextChunk(
chunkId: string,
sessionId?: string | null,
): boolean {
if (!sessionId) return true;
const claimed = tryClaimSessionKey(sessionId, CONTEXT_CHUNK_KIND, chunkId);
if (claimed) {
syncSessionFileFromClaims(sessionId, CONTEXT_CHUNK_KIND);
}
return claimed;
}
export function selectManagedContextChunk(
orderedSkills: string[],
options?: ManagedContextChunkOptions,
): ManagedContextChunk | null {
if (orderedSkills.length === 0) return null;
const topSkill = orderedSkills[0];
const chunk = getManagedContextChunkForSkill(topSkill, options);
if (!chunk) return null;
return claimManagedContextChunk(chunk.chunkId, options?.sessionId) ? chunk : null;
}
export { DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES };
+19 -1
View File
@@ -26,6 +26,7 @@ import { searchSkills, initializeLexicalIndex } from "./lexical-index.mjs";
import { analyzePrompt } from "./prompt-analysis.mjs";
import { createLogger, logDecision } from "./logger.mjs";
import { trackBaseEvents } from "./telemetry.mjs";
import { selectManagedContextChunk } from "./vercel-context.mjs";
var MAX_SKILLS = 2;
var DEFAULT_INJECTION_BUDGET_BYTES = 8e3;
var MIN_PROMPT_LENGTH = 10;
@@ -471,7 +472,7 @@ function deduplicateAndInject(matches, skills, logger, platform) {
matchedSkills: allMatched
};
}
function formatOutput(parts, matchedSkills, injectedSkills, summaryOnly, droppedByCap, droppedByBudget, promptMatchReasons, skillMap, platform = "claude-code", env) {
function formatOutput(parts, matchedSkills, injectedSkills, contextChunks, summaryOnly, droppedByCap, droppedByBudget, promptMatchReasons, skillMap, platform = "claude-code", env) {
if (parts.length === 0) {
return formatEmptyOutput(platform, env);
}
@@ -480,6 +481,7 @@ function formatOutput(parts, matchedSkills, injectedSkills, summaryOnly, dropped
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills,
contextChunks,
summaryOnly,
droppedByBudget
};
@@ -658,6 +660,20 @@ function run() {
});
if (log.active) timing.inject = Math.round(log.now() - tInject);
const { parts, loaded, summaryOnly } = injectResult;
const injectedContextChunks = [];
const chunk = selectManagedContextChunk(loaded, {
pluginRoot: PLUGIN_ROOT,
sessionId
});
if (chunk) {
parts.push(chunk.wrapped);
injectedContextChunks.push(chunk.chunkId);
log.debug("managed-context-chunk-injected", {
chunkId: chunk.chunkId,
skill: chunk.skill,
bytes: chunk.bytes
});
}
let syncedSeenSkills = seenState;
if (hasFileDedup) {
syncedSeenSkills = syncPromptSeenSkillClaims(sessionId, loaded);
@@ -685,6 +701,7 @@ function run() {
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills: loaded,
contextChunks: injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget
@@ -723,6 +740,7 @@ function run() {
parts,
matchedSkills,
loaded,
injectedContextChunks,
summaryOnly,
droppedByCap,
droppedByBudget,
+109
View File
@@ -0,0 +1,109 @@
// hooks/src/vercel-context.mts
import { join } from "path";
import {
pluginRoot as resolvePluginRoot,
safeReadFile,
syncSessionFileFromClaims,
tryClaimSessionKey
} from "./hook-env.mjs";
var PLUGIN_ROOT = resolvePluginRoot();
var DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES = 1800;
var CONTEXT_CHUNK_KIND = "seen-context-chunks";
var SKILL_TO_CHUNK = {
"nextjs": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-cache-components": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-upgrade": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"turbopack": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"next-forge": { chunkId: "nextjs-platform", heading: "Next.js and Rendering" },
"ai-sdk": { chunkId: "ai-stack", heading: "AI Stack" },
"ai-gateway": { chunkId: "ai-stack", heading: "AI Stack" },
"chat-sdk": { chunkId: "ai-stack", heading: "AI Stack" },
"vercel-functions": { chunkId: "compute-routing", heading: "Compute and Routing" },
"routing-middleware": { chunkId: "compute-routing", heading: "Compute and Routing" },
"runtime-cache": { chunkId: "compute-routing", heading: "Compute and Routing" },
"vercel-sandbox": { chunkId: "compute-routing", heading: "Compute and Routing" },
"vercel-cli": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"deployments-cicd": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"env-vars": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"marketplace": { chunkId: "deploy-operations", heading: "Deploy and Operations" },
"vercel-storage": { chunkId: "storage-data", heading: "Storage and Data" },
"workflow": { chunkId: "workflow-durable", heading: "Workflow and Durability" }
};
function parseHeadingSpec(spec) {
const match = spec.match(/^(#{1,6})\s+(.+)$/);
if (match) {
return { level: match[1].length, text: match[2].trim().toLowerCase() };
}
return { level: null, text: spec.trim().toLowerCase() };
}
function extractDirectSection(markdown, headingSpec) {
const { level: specLevel, text: specText } = parseHeadingSpec(headingSpec);
const lines = markdown.split("\n");
let startLine = -1;
let headingLevel = 0;
for (let i = 0; i < lines.length; i += 1) {
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
if (!headingMatch) continue;
const lineLevel = headingMatch[1].length;
const lineText = headingMatch[2].trim().toLowerCase();
if (lineText === specText && (specLevel === null || lineLevel === specLevel)) {
startLine = i;
headingLevel = lineLevel;
break;
}
}
if (startLine === -1) return "";
const contentLines = [];
for (let i = startLine + 1; i < lines.length; i += 1) {
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
if (headingMatch && headingMatch[1].length <= headingLevel) {
break;
}
contentLines.push(lines[i]);
}
return contentLines.join("\n").trim();
}
function getManagedContextChunkForSkill(skill, options) {
const mapping = SKILL_TO_CHUNK[skill];
if (!mapping) return null;
const root = options?.pluginRoot ?? PLUGIN_ROOT;
const raw = safeReadFile(join(root, "vercel.md"));
if (raw === null) return null;
const content = extractDirectSection(raw, mapping.heading);
if (!content) return null;
const wrapped = `<!-- vercel-context-chunk:${mapping.chunkId} -->
${content}
<!-- /vercel-context-chunk:${mapping.chunkId} -->`;
const bytes = Buffer.byteLength(wrapped, "utf8");
const budget = options?.budgetBytes ?? DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES;
if (bytes > budget) return null;
return {
chunkId: mapping.chunkId,
heading: mapping.heading,
skill,
content,
wrapped,
bytes
};
}
function claimManagedContextChunk(chunkId, sessionId) {
if (!sessionId) return true;
const claimed = tryClaimSessionKey(sessionId, CONTEXT_CHUNK_KIND, chunkId);
if (claimed) {
syncSessionFileFromClaims(sessionId, CONTEXT_CHUNK_KIND);
}
return claimed;
}
function selectManagedContextChunk(orderedSkills, options) {
if (orderedSkills.length === 0) return null;
const topSkill = orderedSkills[0];
const chunk = getManagedContextChunkForSkill(topSkill, options);
if (!chunk) return null;
return claimManagedContextChunk(chunk.chunkId, options?.sessionId) ? chunk : null;
}
export {
DEFAULT_CONTEXT_CHUNK_BUDGET_BYTES,
claimManagedContextChunk,
getManagedContextChunkForSkill,
selectManagedContextChunk
};
+64
View File
@@ -0,0 +1,64 @@
import { describe, test, expect } from "bun:test";
import { join, resolve } from "node:path";
const ROOT = resolve(import.meta.dirname, "..");
const HOOK_SCRIPT = join(ROOT, "hooks", "inject-claude-md.mjs");
async function runHook(
payload: Record<string, unknown>,
env?: Record<string, string | undefined>,
): Promise<{ code: number; stdout: string; stderr: string }> {
const mergedEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
for (const [key, value] of Object.entries(env || {})) {
if (value === undefined) {
delete mergedEnv[key];
} else {
mergedEnv[key] = value;
}
}
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: mergedEnv,
});
proc.stdin.write(JSON.stringify(payload));
proc.stdin.end();
const code = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { code, stdout, stderr };
}
describe("inject-claude-md", () => {
test("injects thin session context instead of the full vercel ecosystem graph", async () => {
const { code, stdout } = await runHook({ session_id: "inject-thin-session" });
expect(code).toBe(0);
expect(stdout).toContain("Vercel Plugin Session Context");
expect(stdout).toContain("Vercel Knowledge Updates");
expect(stdout).not.toContain("Vercel Ecosystem — Relational Knowledge Graph");
});
test("appends greenfield guidance when VERCEL_PLUGIN_GREENFIELD=true", async () => {
const { code, stdout } = await runHook(
{ session_id: "inject-thin-greenfield" },
{ VERCEL_PLUGIN_GREENFIELD: "true" },
);
expect(code).toBe(0);
expect(stdout).toContain("Greenfield execution mode");
});
test("cursor payload returns flat JSON with thin additional context", async () => {
const { code, stdout } = await runHook({
conversation_id: "inject-thin-cursor",
cursor_version: "1.0.0",
workspace_roots: [ROOT],
});
expect(code).toBe(0);
const result = JSON.parse(stdout);
expect(result.additional_context).toContain("Vercel Plugin Session Context");
expect(result.additional_context).not.toContain("Vercel Ecosystem — Relational Knowledge Graph");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
getManagedContextChunkForSkill,
selectManagedContextChunk,
} from "../hooks/src/vercel-context.mts";
const ROOT = resolve(import.meta.dirname, "..");
const PRETOOL_HOOK = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
const PROMPT_HOOK = join(ROOT, "hooks", "user-prompt-submit-skill-inject.mjs");
function cleanupSessionArtifacts(sessionId: string): void {
const prefix = `vercel-plugin-${sessionId}-`;
try {
for (const entry of readdirSync(tmpdir())) {
if (entry.startsWith(prefix)) {
rmSync(join(tmpdir(), entry), { recursive: true, force: true });
}
}
} catch {
// best-effort cleanup for tests
}
}
async function runPretoolHook(
input: object,
sessionId: string,
): Promise<{ code: number; stdout: string; stderr: string }> {
const proc = Bun.spawn(["node", PRETOOL_HOOK], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...(process.env as Record<string, string>) },
});
proc.stdin.write(JSON.stringify({ ...input, session_id: sessionId }));
proc.stdin.end();
const code = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { code, stdout, stderr };
}
async function runPromptHook(
prompt: string,
sessionId: string,
envOverrides?: Record<string, string>,
): Promise<{ code: number; stdout: string; stderr: string }> {
const proc = Bun.spawn(["node", PROMPT_HOOK], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...(process.env as Record<string, string>), ...(envOverrides || {}) },
});
proc.stdin.write(JSON.stringify({
prompt,
session_id: sessionId,
cwd: ROOT,
hook_event_name: "UserPromptSubmit",
}));
proc.stdin.end();
const code = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
return { code, stdout, stderr };
}
function createTestSession(): string {
return `vercel-context-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
describe("managed vercel context chunks", () => {
test("extracts a small nextjs chunk from vercel.md", () => {
const chunk = getManagedContextChunkForSkill("nextjs", { pluginRoot: ROOT });
expect(chunk).not.toBeNull();
expect(chunk?.chunkId).toBe("nextjs-platform");
expect(chunk?.wrapped).toContain("Default to Next.js App Router");
expect(chunk?.wrapped).toContain("vercel-context-chunk:nextjs-platform");
});
test("returns null for unmapped skills", () => {
expect(getManagedContextChunkForSkill("shadcn", { pluginRoot: ROOT })).toBeNull();
});
test("deduplicates chunk claims per session", () => {
const testSession = createTestSession();
const first = selectManagedContextChunk(["nextjs"], {
pluginRoot: ROOT,
sessionId: testSession,
});
const second = selectManagedContextChunk(["nextjs"], {
pluginRoot: ROOT,
sessionId: testSession,
});
expect(first).not.toBeNull();
expect(second).toBeNull();
cleanupSessionArtifacts(testSession);
});
});
describe("on-demand context injection", () => {
test("pretooluse appends a nextjs chunk after skill injection", async () => {
const testSession = createTestSession();
try {
const { code, stdout } = await runPretoolHook({
tool_name: "Read",
tool_input: { file_path: "/Users/me/project/next.config.ts" },
}, testSession);
expect(code).toBe(0);
const parsed = JSON.parse(stdout);
const ctx = parsed.hookSpecificOutput.additionalContext as string;
expect(ctx).toContain("Skill(");
expect(ctx).toContain("<!-- vercel-context-chunk:nextjs-platform -->");
expect(ctx).toContain("Default to Next.js App Router");
} finally {
cleanupSessionArtifacts(testSession);
}
});
test("prompt hook appends ai chunk once and dedups it across later prompts", async () => {
const testSession = createTestSession();
try {
const first = await runPromptHook(
"I need to use the AI SDK to add streaming text generation to this endpoint",
testSession,
);
expect(first.code).toBe(0);
const firstParsed = JSON.parse(first.stdout);
const firstCtx = firstParsed.hookSpecificOutput.additionalContext as string;
expect(firstCtx).toContain("<!-- vercel-context-chunk:ai-stack -->");
const second = await runPromptHook(
"Build a conversational interface for a Discord bot that responds to mentions",
testSession,
);
expect(second.code).toBe(0);
const secondParsed = JSON.parse(second.stdout);
const secondCtx = secondParsed.hookSpecificOutput.additionalContext as string;
expect(secondCtx).toContain("Skill(chat-sdk)");
expect(secondCtx).not.toContain("<!-- vercel-context-chunk:ai-stack -->");
} finally {
cleanupSessionArtifacts(testSession);
}
});
test("prompt audit log records context chunk ids", async () => {
const testSession = createTestSession();
const auditLogPath = join(tmpdir(), `${testSession}-audit.jsonl`);
try {
const result = await runPromptHook(
"I need to use the AI SDK to add streaming text generation to this endpoint",
testSession,
{ VERCEL_PLUGIN_AUDIT_LOG_FILE: auditLogPath },
);
expect(result.code).toBe(0);
expect(existsSync(auditLogPath)).toBe(true);
const entries = readFileSync(auditLogPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const promptEntry = entries.find((entry) => entry.event === "prompt-skill-injection");
expect(promptEntry).toBeDefined();
expect(promptEntry.injectedSkills).toContain("ai-sdk");
expect(promptEntry.contextChunks).toEqual(["ai-stack"]);
} finally {
cleanupSessionArtifacts(testSession);
rmSync(auditLogPath, { force: true });
}
});
});
+8
View File
@@ -0,0 +1,8 @@
# Vercel Plugin Session Context
Use Vercel guidance only when the current repo, prompt, or tool call makes it relevant.
- Prefer matched skills and official docs over memorized APIs.
- Default Vercel assumptions: Next.js App Router, AI SDK v6, `vercel env` for environment management, and platform-native integrations before custom infrastructure.
- Do not push broad Vercel migrations or product recommendations unless they directly help the current task.
- The full ecosystem graph stays in `vercel.md`; runtime hooks now load only thin, topic-sized chunks on demand.
+47 -1
View File
@@ -808,6 +808,50 @@ Git Push → CI Pipeline → vercel build → vercel deploy --prebuilt
- Use `vercel deploy --prebuilt` in CI for fastest deploys (separate build from deploy).
- For monorepos, use Turborepo with remote caching and `--affected` for efficient CI.
## Session Chunks
### Next.js and Rendering
- Default to Next.js App Router and Server Components; add `'use client'` only where interactivity is required.
- Use `proxy.ts` instead of `middleware.ts` in Next.js 16 and keep proxy logic focused on interception, auth gates, rewrites, and redirects.
- Prefer Cache Components, `next/image`, and `next/font` over custom rendering/caching/font-loading workarounds.
- Treat `@vercel/postgres` and `@vercel/kv` as sunset packages; use modern storage integrations instead.
### AI Stack
- Default to AI SDK v6 plus AI Gateway model strings (`provider/model`) rather than direct provider SDK wiring.
- Use `@ai-sdk/react` for React hooks and keep user-facing AI experiences streaming-first.
- Prefer `toUIMessageStreamResponse()`, `DefaultChatTransport`, and modern tool definitions (`inputSchema`, `outputSchema`) over v5-era patterns.
- Keep AI recommendations scoped to the current task; do not force broad AI stack migrations unless the code already points there.
### Compute and Routing
- Prefer Vercel Functions, Route Handlers, and proxy/routing primitives over custom servers when the task fits platform-native execution.
- Use Fluid Compute, `waitUntil`/`after`, and Runtime Cache for long-running work, background tasks, and shared caching.
- Keep routing guidance focused on the matched boundary: request interception, rewrites/redirects, function behavior, or cache invalidation.
- Do not recommend Edge-only patterns by default when regular Node.js execution solves the problem cleanly.
### Deploy and Operations
- Use `vercel env`, linked projects, and Marketplace-managed provisioning instead of hardcoded secrets or manual config drift.
- For deploy workflows, prefer `vercel deploy`, `--prebuilt` CI flows, and platform-native preview/production promotion patterns.
- Keep environment and deployment advice narrow to the current repo state rather than reciting the whole platform.
- Only surface Marketplace or CLI recommendations when the prompt, files, or commands already imply those workflows.
### Storage and Data
- Prefer current Vercel data integrations such as Neon, Upstash, Blob, and Edge Config over sunset packages.
- Match storage advice to the active need: relational data, cache/queue-style access, blob assets, or low-latency config reads.
- Avoid recommending data migrations unless the codebase is actually using deprecated Vercel storage packages.
- Keep data-layer guidance practical: client choice, env setup, and runtime-fit over product catalog detail.
### Workflow and Durability
- Use Workflow DevKit and DurableAgent when the task needs retries, resumability, crash recovery, or long-lived orchestration.
- Prefer workflow steps over ad-hoc retry loops, timers, and manual state persistence in request handlers.
- Keep workflow recommendations limited to durable execution problems; do not route ordinary request/response code into workflow patterns by default.
- When workflow context is injected, emphasize survival of crashes, retries, and async callback orchestration.
---
## Plugin Mechanics
@@ -816,7 +860,9 @@ This document is part of the **Vercel plugin for Claude Code**. The plugin uses
### SessionStart — Baseline injection
On every session event (`startup`, `resume`, `clear`, `compact`), the `inject-claude-md.mjs` hook runs and feeds the entire contents of `vercel.md` (this file) into the conversation as foundational context. This ensures every session starts with the full Vercel ecosystem knowledge graph.
On every session event (`startup`, `resume`, `clear`, `compact`), the `inject-claude-md.mjs` hook injects a thin Vercel session context plus the `knowledge-update` skill. The full ecosystem graph in `vercel.md` is no longer injected wholesale at session start.
Deeper `vercel.md` guidance is now loaded later in small topic chunks when prompt or tool-time skill matching shows it is relevant. Chunking is deduped per session so the same Vercel topic is not repeated on every prompt.
**Hook config** (`hooks/hooks.json`):
```json