Skip to content

Commit 23ef511

Browse files
jimgqyuclaude
andcommitted
feat: add skills module (SkillCreator, SkillImprover, SkillLoader, SkillRegistry)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c52b443 commit 23ef511

6 files changed

Lines changed: 1648 additions & 0 deletions

File tree

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/**
2+
* creator.ts — SkillCreator: LLM-driven skill creation from task patterns
3+
*
4+
* Detects repeated task patterns in the agent's activity log, proposes
5+
* skill creation via LLM draft generation, and writes SKILL.md files.
6+
*
7+
* Flow:
8+
* 1. trackTask() — record each task execution to ~/.coder/skills/.task-patterns.json
9+
* 2. shouldCreateSkill() — check if pattern repeats enough (≥2) or is complex enough
10+
* 3. generateDraft() → proposeSkill() — LLM generates SKILL.md draft
11+
* 4. writeSkill() — atomic write to ~/.coder/skills/<name>/SKILL.md
12+
*/
13+
14+
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
15+
import { join, dirname } from 'node:path';
16+
import { homedir } from 'node:os';
17+
import { randomUUID } from 'node:crypto';
18+
19+
import type {
20+
Skill,
21+
SkillCreationCandidate,
22+
CallModelFn,
23+
} from './types.js';
24+
import { SkillLoader } from './loader.js';
25+
26+
// ---------------------------------------------------------------------------
27+
// Constants
28+
// ---------------------------------------------------------------------------
29+
30+
const DEFAULT_SKILLS_DIR = join(homedir(), '.coder', 'skills');
31+
const TASK_PATTERNS_FILE = '.task-patterns.json';
32+
const MIN_REPEAT_COUNT = 2;
33+
const MIN_COMPLEXITY = 0.6;
34+
35+
interface TaskPatternEntry {
36+
count: number;
37+
tools: string[];
38+
lastSeen: string;
39+
steps: string[];
40+
description: string;
41+
}
42+
43+
interface TaskPatternsStore {
44+
patterns: Record<string, TaskPatternEntry>;
45+
}
46+
47+
// ---------------------------------------------------------------------------
48+
// SkillCreator
49+
// ---------------------------------------------------------------------------
50+
51+
export class SkillCreator {
52+
private callModel: CallModelFn;
53+
private skillsDir: string;
54+
private loader: SkillLoader;
55+
56+
constructor(config: {
57+
callModel: CallModelFn;
58+
skillsDir?: string;
59+
}) {
60+
this.callModel = config.callModel;
61+
this.skillsDir = config.skillsDir ?? DEFAULT_SKILLS_DIR;
62+
this.loader = new SkillLoader(this.skillsDir);
63+
}
64+
65+
// ── Pattern Detection ───────────────────────────────────────────
66+
67+
shouldCreateSkill(candidate: SkillCreationCandidate): boolean {
68+
return candidate.repeatCount >= MIN_REPEAT_COUNT ||
69+
candidate.complexity >= MIN_COMPLEXITY;
70+
}
71+
72+
// ── Draft Generation ────────────────────────────────────────────
73+
74+
async generateDraft(candidate: SkillCreationCandidate): Promise<string> {
75+
const systemPrompt = this.buildDraftSystemPrompt();
76+
const userPrompt = this.buildDraftUserPrompt(candidate);
77+
78+
let fullResponse = '';
79+
80+
const signal = new AbortController().signal;
81+
const generator = this.callModel({
82+
system: systemPrompt,
83+
messages: [{ role: 'user', content: userPrompt }],
84+
tools: [],
85+
signal,
86+
});
87+
88+
for await (const event of generator) {
89+
const ev = event as Record<string, unknown>;
90+
91+
if (ev.type === 'content_block_delta' && ev.delta) {
92+
const delta = ev.delta as Record<string, unknown>;
93+
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
94+
fullResponse += delta.text;
95+
}
96+
}
97+
98+
if (ev.role === 'assistant' && ev.content) {
99+
if (typeof ev.content === 'string') {
100+
fullResponse = ev.content;
101+
} else if (Array.isArray(ev.content)) {
102+
fullResponse = (ev.content as Array<Record<string, unknown>>)
103+
.filter((b) => b.type === 'text')
104+
.map((b) => String(b.text ?? ''))
105+
.join('\n');
106+
}
107+
}
108+
}
109+
110+
return this.cleanDraft(fullResponse);
111+
}
112+
113+
async proposeSkill(candidate: SkillCreationCandidate): Promise<{
114+
name: string;
115+
draft: string;
116+
reason: string;
117+
}> {
118+
const draft = await this.generateDraft(candidate);
119+
const name = this.extractSkillName(draft) ?? this.generateSkillName(candidate);
120+
121+
const reason = candidate.repeatCount >= MIN_REPEAT_COUNT
122+
? `Detected ${candidate.repeatCount} similar tasks: "${candidate.taskDescription}"`
123+
: `Task complexity (${candidate.complexity.toFixed(1)}) exceeds threshold — creating skill to save time`;
124+
125+
return { name, draft, reason };
126+
}
127+
128+
// ── File Writing ────────────────────────────────────────────────
129+
130+
async writeSkill(name: string, content: string): Promise<string> {
131+
const skillDir = join(this.skillsDir, name);
132+
const skillPath = join(skillDir, 'SKILL.md');
133+
134+
if (existsSync(skillPath)) {
135+
return skillPath;
136+
}
137+
138+
if (!existsSync(skillDir)) {
139+
mkdirSync(skillDir, { recursive: true });
140+
}
141+
142+
const tmpPath = `${skillPath}.coder-tmp-${randomUUID()}`;
143+
try {
144+
writeFileSync(tmpPath, content, 'utf-8');
145+
renameSync(tmpPath, skillPath);
146+
} catch (err) {
147+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
148+
throw err;
149+
}
150+
151+
return skillPath;
152+
}
153+
154+
// ── Task Tracking ───────────────────────────────────────────────
155+
156+
trackTask(description: string, toolsUsed: string[], steps: string[]): void {
157+
const patternsPath = join(this.skillsDir, TASK_PATTERNS_FILE);
158+
const store = this.loadTaskPatterns(patternsPath);
159+
const key = this.derivePatternKey(description);
160+
161+
if (store.patterns[key]) {
162+
store.patterns[key].count++;
163+
store.patterns[key].lastSeen = new Date().toISOString();
164+
store.patterns[key].tools = this.mergeTools(store.patterns[key].tools, toolsUsed);
165+
store.patterns[key].steps = steps.length > 0 ? steps : store.patterns[key].steps;
166+
store.patterns[key].description = description;
167+
} else {
168+
store.patterns[key] = {
169+
count: 1,
170+
tools: toolsUsed,
171+
lastSeen: new Date().toISOString(),
172+
steps,
173+
description,
174+
};
175+
}
176+
177+
this.saveTaskPatterns(patternsPath, store);
178+
}
179+
180+
getTaskPatterns(): Map<string, { count: number; tools: string[] }> {
181+
const patternsPath = join(this.skillsDir, TASK_PATTERNS_FILE);
182+
const store = this.loadTaskPatterns(patternsPath);
183+
const result = new Map<string, { count: number; tools: string[] }>();
184+
185+
for (const [key, entry] of Object.entries(store.patterns)) {
186+
result.set(key, { count: entry.count, tools: entry.tools });
187+
}
188+
189+
return result;
190+
}
191+
192+
// ── Private: LLM Prompts ────────────────────────────────────────
193+
194+
private buildDraftSystemPrompt(): string {
195+
return `You are a skill author for the Coder Agent platform. Your task is to create a SKILL.md file that teaches an AI agent how to perform a specific task.
196+
197+
## SKILL.md Format
198+
199+
Every SKILL.md has two sections:
200+
201+
### 1. YAML Frontmatter (between --- markers)
202+
\`\`\`yaml
203+
---
204+
name: kebab-case-name
205+
description: One-line description for progressive disclosure
206+
version: "1.0"
207+
triggers:
208+
- "Natural language trigger 1"
209+
- "Natural language trigger 2"
210+
tools:
211+
- ToolName1
212+
- ToolName2
213+
tags:
214+
- category
215+
author: auto
216+
createdAt: ISO timestamp
217+
updatedAt: ISO timestamp
218+
---
219+
\`\`\`
220+
221+
### 2. Markdown Body
222+
- Clear step-by-step instructions
223+
- Code examples where appropriate
224+
- Common pitfalls and edge cases
225+
- Expected outputs or verification steps
226+
227+
## Rules
228+
- The name MUST be kebab-case (lowercase, hyphens)
229+
- Triggers should be natural language phrases users might say
230+
- Steps should be actionable (tool calls, commands, file paths)
231+
- Keep it concise but complete — a new agent should be able to follow it`;
232+
}
233+
234+
private buildDraftUserPrompt(candidate: SkillCreationCandidate): string {
235+
const steps = candidate.steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
236+
const tools = candidate.toolsUsed.join(', ');
237+
238+
return `Create a SKILL.md for the following task pattern:
239+
240+
## Task Description
241+
${candidate.taskDescription}
242+
243+
## Steps Executed
244+
${steps}
245+
246+
## Tools Used
247+
${tools}
248+
249+
## Complexity Score
250+
${candidate.complexity.toFixed(1)} / 1.0
251+
252+
## Repeat Count
253+
${candidate.repeatCount} time(s)
254+
255+
Generate a complete SKILL.md with appropriate frontmatter and body.`;
256+
}
257+
258+
// ── Private: Draft Parsing ──────────────────────────────────────
259+
260+
private cleanDraft(raw: string): string {
261+
let cleaned = raw.trim();
262+
263+
if (cleaned.startsWith('```')) {
264+
const firstNewline = cleaned.indexOf('\n');
265+
cleaned = firstNewline > 0 ? cleaned.slice(firstNewline + 1) : cleaned;
266+
}
267+
if (cleaned.endsWith('```')) {
268+
cleaned = cleaned.slice(0, -3).trim();
269+
}
270+
271+
if (!cleaned.startsWith('---')) {
272+
cleaned = '---\n' + cleaned;
273+
}
274+
275+
return cleaned;
276+
}
277+
278+
private extractSkillName(draft: string): string | null {
279+
const lines = draft.split('\n');
280+
let inFrontmatter = false;
281+
282+
for (const line of lines) {
283+
const trimmed = line.trim();
284+
if (trimmed === '---') {
285+
if (!inFrontmatter) {
286+
inFrontmatter = true;
287+
continue;
288+
} else {
289+
break;
290+
}
291+
}
292+
293+
if (inFrontmatter) {
294+
const match = /^name:\s*(.+)$/.exec(trimmed);
295+
if (match) {
296+
return match[1]!.trim().replace(/["']/g, '');
297+
}
298+
}
299+
}
300+
301+
return null;
302+
}
303+
304+
private generateSkillName(candidate: SkillCreationCandidate): string {
305+
return candidate.taskDescription
306+
.toLowerCase()
307+
.replace(/[^a-z0-9\s-]/g, '')
308+
.replace(/\s+/g, '-')
309+
.slice(0, 50)
310+
.replace(/-+$/, '');
311+
}
312+
313+
// ── Private: Pattern Storage ────────────────────────────────────
314+
315+
private loadTaskPatterns(path: string): TaskPatternsStore {
316+
try {
317+
if (existsSync(path)) {
318+
const raw = readFileSync(path, 'utf-8');
319+
return JSON.parse(raw) as TaskPatternsStore;
320+
}
321+
} catch {
322+
// Corrupt file — start fresh
323+
}
324+
return { patterns: {} };
325+
}
326+
327+
private saveTaskPatterns(path: string, store: TaskPatternsStore): void {
328+
const dir = dirname(path);
329+
if (!existsSync(dir)) {
330+
mkdirSync(dir, { recursive: true });
331+
}
332+
333+
const tmpPath = `${path}.coder-tmp-${randomUUID()}`;
334+
try {
335+
writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf-8');
336+
renameSync(tmpPath, path);
337+
} catch (err) {
338+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
339+
throw err;
340+
}
341+
}
342+
343+
private derivePatternKey(description: string): string {
344+
const stopWords = new Set([
345+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
346+
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'and', 'or',
347+
'it', 'its', 'this', 'that', 'from', 'by', 'as', 'into',
348+
'create', 'set', 'up', 'fix', 'add', 'make', 'use', 'using',
349+
]);
350+
351+
const words = description
352+
.toLowerCase()
353+
.replace(/[^a-z0-9\s-]/g, '')
354+
.split(/\s+/)
355+
.filter((w) => w.length > 1 && !stopWords.has(w));
356+
357+
return words.slice(0, 4).join('-') || 'unknown-pattern';
358+
}
359+
360+
private mergeTools(existing: string[], incoming: string[]): string[] {
361+
const set = new Set(existing);
362+
for (const t of incoming) {
363+
set.add(t);
364+
}
365+
return Array.from(set).sort();
366+
}
367+
}

0 commit comments

Comments
 (0)