Skip to content

Commit d23fccd

Browse files
committed
feat: add skill resource management and update skill document rendering
1 parent bd35d8f commit d23fccd

3 files changed

Lines changed: 216 additions & 22 deletions

File tree

src/prompt.ts

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,30 @@ type PromptToolOptions = {
100100
};
101101

102102
const DEFAULT_SKILL_TEMPLATES = ["karpathy-guidelines.md"];
103+
const DEFAULT_SKILL_RESOURCE_FILE_LIMIT = 50;
104+
const SKILL_RESOURCE_EXCLUDED_DIRS = new Set([
105+
".cache",
106+
".git",
107+
".next",
108+
".turbo",
109+
"build",
110+
"coverage",
111+
"dist",
112+
"node_modules",
113+
"out",
114+
]);
115+
116+
export type SkillPromptDocument = {
117+
name: string;
118+
content: string;
119+
path?: string;
120+
skillFilePath?: string;
121+
};
122+
123+
type SkillResourceListing = {
124+
files: string[];
125+
truncated: boolean;
126+
};
103127

104128
function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string {
105129
const toolsDir = path.join(extensionRoot, "templates", "tools");
@@ -149,14 +173,99 @@ export function getDefaultSkillPrompt(): string {
149173
return "";
150174
}
151175

152-
const blocks = skillDocs.map(
153-
(skill) => `<${skill.name}-skill>
154-
${skill.content}
155-
</${skill.name}-skill>`
156-
);
176+
return buildSkillDocumentsPrompt(skillDocs);
177+
}
178+
179+
export function buildSkillDocumentsPrompt(skills: SkillPromptDocument[]): string {
180+
const blocks = skills.map((skill) => renderSkillDocumentBlock(skill));
157181
return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`;
158182
}
159183

184+
function renderSkillDocumentBlock(skill: SkillPromptDocument): string {
185+
const pathAttribute = skill.path ? ` path="${escapeXml(skill.path)}"` : "";
186+
const resources = renderSkillResources(skill.skillFilePath);
187+
return `<${skill.name}-skill${pathAttribute}>
188+
${skill.content}${resources}
189+
</${skill.name}-skill>`;
190+
}
191+
192+
function renderSkillResources(skillFilePath?: string): string {
193+
if (!skillFilePath) {
194+
return "";
195+
}
196+
197+
const listing = listSkillResourceFiles(skillFilePath, DEFAULT_SKILL_RESOURCE_FILE_LIMIT);
198+
if (listing.files.length === 0 && !listing.truncated) {
199+
return "";
200+
}
201+
202+
const fileLines = listing.files.map((file) => ` <file>${escapeXml(file)}</file>`);
203+
const noteLine = listing.truncated
204+
? [` <note>Listing capped at ${DEFAULT_SKILL_RESOURCE_FILE_LIMIT} files and may be incomplete.</note>`]
205+
: [];
206+
return `\n\n<skill_resources>\n${[...fileLines, ...noteLine].join("\n")}\n</skill_resources>`;
207+
}
208+
209+
function listSkillResourceFiles(skillFilePath: string, limit: number): SkillResourceListing {
210+
const skillDir = path.dirname(skillFilePath);
211+
const files: string[] = [];
212+
let truncated = false;
213+
214+
const visit = (dir: string, relativeDir = ""): void => {
215+
if (files.length > limit) {
216+
truncated = true;
217+
return;
218+
}
219+
220+
let entries: fs.Dirent[];
221+
try {
222+
entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
223+
} catch {
224+
return;
225+
}
226+
227+
for (const entry of entries) {
228+
if (entry.name.startsWith(".")) {
229+
continue;
230+
}
231+
232+
const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
233+
const fullPath = path.join(dir, entry.name);
234+
if (entry.isDirectory()) {
235+
if (SKILL_RESOURCE_EXCLUDED_DIRS.has(entry.name)) {
236+
continue;
237+
}
238+
visit(fullPath, relativePath);
239+
if (truncated) {
240+
return;
241+
}
242+
continue;
243+
}
244+
245+
if (!entry.isFile() || entry.name === "SKILL.md") {
246+
continue;
247+
}
248+
249+
files.push(toPosixPath(relativePath));
250+
if (files.length > limit) {
251+
truncated = true;
252+
return;
253+
}
254+
}
255+
};
256+
257+
visit(skillDir);
258+
return { files: files.slice(0, limit), truncated };
259+
}
260+
261+
function toPosixPath(filePath: string): string {
262+
return filePath.split(path.sep).join("/");
263+
}
264+
265+
function escapeXml(value: string): string {
266+
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
267+
}
268+
160269
function getCurrentDateAndModelPrompt(model?: string): string {
161270
const date = new Date();
162271
let prompt = `今天是${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}日。随着对话的进行,时间在流逝。`;

src/session.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { buildThinkingRequestOptions } from "./common/openai-thinking";
1010
import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities";
1111
import { readTextFileWithMetadata } from "./common/file-utils";
1212
import {
13+
buildSkillDocumentsPrompt,
1314
getCompactPrompt,
1415
getDefaultSkillPrompt,
1516
getExtensionRoot,
@@ -878,6 +879,18 @@ ${agentInstructions}
878879
return path.join(os.homedir(), skillPath);
879880
}
880881

882+
private buildSkillPrompt(skill: SkillInfo): string {
883+
const skillPath = this.resolveSkillPath(skill.path);
884+
return buildSkillDocumentsPrompt([
885+
{
886+
name: skill.name,
887+
content: fs.readFileSync(skillPath, "utf8"),
888+
path: skillPath,
889+
skillFilePath: skillPath,
890+
},
891+
]);
892+
}
893+
881894
private readSkillInfo(skillPath: string, displayPath: string, fallbackName: string): SkillInfo {
882895
const fallbackSkill: SkillInfo = {
883896
name: fallbackName.replace(/_/g, "-"),
@@ -1101,11 +1114,7 @@ ${agentInstructions}
11011114
if (skill.isLoaded) {
11021115
continue;
11031116
}
1104-
const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8");
1105-
const skillPrompt = `Use the skill document below to assist the user:\n
1106-
<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}">
1107-
${skillMd}
1108-
</${skill.name}-skill>`;
1117+
const skillPrompt = this.buildSkillPrompt(skill);
11091118
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
11101119
this.appendSessionMessage(sessionId, skillMessage);
11111120
this.onAssistantMessage(skillMessage, true);
@@ -1180,11 +1189,7 @@ ${skillMd}
11801189
if (skill.isLoaded) {
11811190
continue;
11821191
}
1183-
const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8");
1184-
const skillPrompt = `Use the skill document below to assist the user:\n
1185-
<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}">
1186-
${skillMd}
1187-
</${skill.name}-skill>`;
1192+
const skillPrompt = this.buildSkillPrompt(skill);
11881193
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
11891194
this.appendSessionMessage(sessionId, skillMessage);
11901195
this.onAssistantMessage(skillMessage, true);
@@ -2335,11 +2340,7 @@ ${skillMd}
23352340
if (skill.isLoaded) {
23362341
continue;
23372342
}
2338-
const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8");
2339-
const skillPrompt = `Use the skill document below to assist the user:\n
2340-
<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}">
2341-
${skillMd}
2342-
</${skill.name}-skill>`;
2343+
const skillPrompt = this.buildSkillPrompt(skill);
23432344
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
23442345
this.appendSessionMessage(sessionId, skillMessage);
23452346
this.onAssistantMessage(skillMessage, true);

src/tests/prompt.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1-
import { test } from "node:test";
1+
import { afterEach, test } from "node:test";
22
import assert from "node:assert/strict";
33
import * as fs from "fs";
4+
import * as os from "os";
45
import * as path from "path";
56
import { fileURLToPath } from "url";
6-
import { getDefaultSkillPrompt, getRuntimeContext, getSystemPrompt, getTools } from "../prompt";
7+
import {
8+
buildSkillDocumentsPrompt,
9+
getDefaultSkillPrompt,
10+
getRuntimeContext,
11+
getSystemPrompt,
12+
getTools,
13+
} from "../prompt";
714

815
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
16+
const tempDirs: string[] = [];
17+
18+
afterEach(() => {
19+
for (const dir of tempDirs.splice(0)) {
20+
fs.rmSync(dir, { recursive: true, force: true });
21+
}
22+
});
23+
24+
function createTempDir(prefix: string): string {
25+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
26+
tempDirs.push(dir);
27+
return dir;
28+
}
929

1030
test("getTools always includes WebSearch", () => {
1131
const names = getTools().map((tool) => tool.function.name);
@@ -68,6 +88,70 @@ test("getDefaultSkillPrompt loads the default skill template", () => {
6888
assert.equal(prompt.includes('path="templates/skills/'), false);
6989
});
7090

91+
test("buildSkillDocumentsPrompt lists skill resources", () => {
92+
const skillDir = createTempDir("deepcode-skill-resources-");
93+
fs.mkdirSync(path.join(skillDir, "scripts"), { recursive: true });
94+
fs.mkdirSync(path.join(skillDir, "references"), { recursive: true });
95+
const skillPath = path.join(skillDir, "SKILL.md");
96+
fs.writeFileSync(skillPath, "# PDF Skill\n", "utf8");
97+
fs.writeFileSync(path.join(skillDir, "scripts", "extract.py"), "print('extract')\n", "utf8");
98+
fs.writeFileSync(path.join(skillDir, "scripts", "merge.py"), "print('merge')\n", "utf8");
99+
fs.writeFileSync(path.join(skillDir, "references", "pdf-spec-summary.md"), "# PDF Spec\n", "utf8");
100+
101+
const prompt = buildSkillDocumentsPrompt([
102+
{ name: "pdf", content: "# PDF Skill", path: skillPath, skillFilePath: skillPath },
103+
]);
104+
105+
assert.equal(prompt.includes(`<pdf-skill path="${skillPath}">`), true);
106+
assert.equal(prompt.includes("<skill_resources>"), true);
107+
assert.equal(prompt.includes("<file>scripts/extract.py</file>"), true);
108+
assert.equal(prompt.includes("<file>scripts/merge.py</file>"), true);
109+
assert.equal(prompt.includes("<file>references/pdf-spec-summary.md</file>"), true);
110+
assert.equal(prompt.includes("<file>SKILL.md</file>"), false);
111+
});
112+
113+
test("buildSkillDocumentsPrompt caps large skill resource listings", () => {
114+
const skillDir = createTempDir("deepcode-skill-resource-cap-");
115+
const skillPath = path.join(skillDir, "SKILL.md");
116+
fs.writeFileSync(skillPath, "# Large Skill\n", "utf8");
117+
for (let index = 0; index < 55; index += 1) {
118+
fs.writeFileSync(path.join(skillDir, `file-${String(index).padStart(2, "0")}.txt`), "resource\n", "utf8");
119+
}
120+
121+
const prompt = buildSkillDocumentsPrompt([
122+
{ name: "large", content: "# Large Skill", path: skillPath, skillFilePath: skillPath },
123+
]);
124+
125+
assert.equal((prompt.match(/<file>/g) ?? []).length, 50);
126+
assert.equal(prompt.includes("<file>file-49.txt</file>"), true);
127+
assert.equal(prompt.includes("<file>file-50.txt</file>"), false);
128+
assert.equal(prompt.includes("Listing capped at 50 files and may be incomplete."), true);
129+
});
130+
131+
test("buildSkillDocumentsPrompt excludes hidden and generated skill resources", () => {
132+
const skillDir = createTempDir("deepcode-skill-resource-exclusions-");
133+
fs.mkdirSync(path.join(skillDir, ".hidden"), { recursive: true });
134+
fs.mkdirSync(path.join(skillDir, "node_modules", "pkg"), { recursive: true });
135+
fs.mkdirSync(path.join(skillDir, "dist"), { recursive: true });
136+
const skillPath = path.join(skillDir, "SKILL.md");
137+
fs.writeFileSync(skillPath, "# Clean Skill\n", "utf8");
138+
fs.writeFileSync(path.join(skillDir, ".secret.txt"), "hidden\n", "utf8");
139+
fs.writeFileSync(path.join(skillDir, ".hidden", "file.txt"), "hidden\n", "utf8");
140+
fs.writeFileSync(path.join(skillDir, "node_modules", "pkg", "index.js"), "module.exports = {}\n", "utf8");
141+
fs.writeFileSync(path.join(skillDir, "dist", "bundle.js"), "bundle\n", "utf8");
142+
fs.writeFileSync(path.join(skillDir, "README.md"), "# Resource\n", "utf8");
143+
144+
const prompt = buildSkillDocumentsPrompt([
145+
{ name: "clean", content: "# Clean Skill", path: skillPath, skillFilePath: skillPath },
146+
]);
147+
148+
assert.equal(prompt.includes("<file>README.md</file>"), true);
149+
assert.equal(prompt.includes(".secret.txt"), false);
150+
assert.equal(prompt.includes(".hidden/file.txt"), false);
151+
assert.equal(prompt.includes("node_modules/pkg/index.js"), false);
152+
assert.equal(prompt.includes("dist/bundle.js"), false);
153+
});
154+
71155
test("getSystemPrompt does not include current date guidance", () => {
72156
const now = new Date();
73157
const expected = `今天是${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}日。随着对话的进行,时间在流逝。`;

0 commit comments

Comments
 (0)