Skip to content

Commit 1837df5

Browse files
unraidclaude
andcommitted
feat: 添加 skill learning 技能学习闭环系统
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 04c7ed4 commit 1837df5

64 files changed

Lines changed: 11009 additions & 36 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ src/utils/vendor/
1919
/*.png
2020
*.bmp
2121

22+
# Internal system prompt documents
23+
Claude-Opus-*.txt
24+
Claude-Sonnet-*.txt
25+
Claude-Haiku-*.txt
26+
2227
# Agent / tool state dirs
2328
.swarm/
2429
.agents/__pycache__/

AGENTS.md

Lines changed: 283 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { z } from 'zod/v4'
2+
import type { ToolResultBlockParam } from 'src/Tool.js'
3+
import { buildTool } from 'src/Tool.js'
4+
import { lazySchema } from 'src/utils/lazySchema.js'
5+
import {
6+
DISCOVER_SKILLS_TOOL_NAME,
7+
DESCRIPTION,
8+
DISCOVER_SKILLS_PROMPT,
9+
} from './prompt.js'
10+
11+
const inputSchema = lazySchema(() =>
12+
z.strictObject({
13+
description: z
14+
.string()
15+
.describe(
16+
'Description of what you want to do. Be specific — e.g. "deploy a Next.js app to Cloudflare Workers" rather than just "deploy".',
17+
),
18+
limit: z
19+
.number()
20+
.optional()
21+
.describe('Maximum number of results to return (default: 5)'),
22+
}),
23+
)
24+
type InputSchema = ReturnType<typeof inputSchema>
25+
type DiscoverInput = z.infer<InputSchema>
26+
27+
type DiscoverOutput = {
28+
results: Array<{ name: string; description: string; score: number }>
29+
count: number
30+
}
31+
32+
export const DiscoverSkillsTool = buildTool({
33+
name: DISCOVER_SKILLS_TOOL_NAME,
34+
searchHint: 'find search discover skills commands tools capabilities',
35+
maxResultSizeChars: 10_000,
36+
strict: true,
37+
38+
get inputSchema(): InputSchema {
39+
return inputSchema()
40+
},
41+
42+
async description() {
43+
return DESCRIPTION
44+
},
45+
async prompt() {
46+
return DISCOVER_SKILLS_PROMPT
47+
},
48+
49+
isConcurrencySafe() {
50+
return true
51+
},
52+
isReadOnly() {
53+
return true
54+
},
55+
56+
userFacingName() {
57+
return 'Discover Skills'
58+
},
59+
60+
renderToolUseMessage(input: Partial<DiscoverInput>) {
61+
return `Searching skills: ${input.description?.slice(0, 80) ?? '...'}`
62+
},
63+
64+
mapToolResultToToolResultBlockParam(
65+
content: DiscoverOutput,
66+
toolUseID: string,
67+
): ToolResultBlockParam {
68+
if (content.count === 0) {
69+
return {
70+
tool_use_id: toolUseID,
71+
type: 'tool_result',
72+
content: 'No matching skills found for that description.',
73+
}
74+
}
75+
const lines = content.results.map(
76+
(r, i) =>
77+
`${i + 1}. **${r.name}** (score: ${r.score.toFixed(2)})\n ${r.description}`,
78+
)
79+
return {
80+
tool_use_id: toolUseID,
81+
type: 'tool_result',
82+
content: `Found ${content.count} relevant skill(s):\n\n${lines.join('\n\n')}`,
83+
}
84+
},
85+
86+
async call(input: DiscoverInput, context) {
87+
const { getSkillIndex, searchSkills } = await import(
88+
'src/services/skillSearch/localSearch.js'
89+
)
90+
const { getCwd } = await import('src/utils/cwd.js')
91+
const cwd = getCwd()
92+
93+
const index = await getSkillIndex(cwd)
94+
const results = searchSkills(input.description, index, input.limit ?? 5)
95+
96+
return {
97+
data: {
98+
results: results.map(r => ({
99+
name: r.name,
100+
description: r.description,
101+
score: r.score,
102+
})),
103+
count: results.length,
104+
},
105+
}
106+
},
107+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { DISCOVER_SKILLS_TOOL_NAME } from '../prompt.js'
3+
4+
describe('DiscoverSkillsTool', () => {
5+
test('DISCOVER_SKILLS_TOOL_NAME is not empty', () => {
6+
expect(DISCOVER_SKILLS_TOOL_NAME).toBe('DiscoverSkills')
7+
expect(DISCOVER_SKILLS_TOOL_NAME.length).toBeGreaterThan(0)
8+
})
9+
10+
test('tool exports are functions', async () => {
11+
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
12+
expect(DiscoverSkillsTool).toBeDefined()
13+
expect(DiscoverSkillsTool.name).toBe('DiscoverSkills')
14+
expect(typeof DiscoverSkillsTool.call).toBe('function')
15+
})
16+
17+
test('tool has correct metadata', async () => {
18+
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
19+
expect(await DiscoverSkillsTool.description()).toContain('skill')
20+
expect(DiscoverSkillsTool.userFacingName()).toBe('Discover Skills')
21+
expect(DiscoverSkillsTool.isReadOnly()).toBe(true)
22+
expect(DiscoverSkillsTool.isConcurrencySafe()).toBe(true)
23+
})
24+
25+
test('renderToolUseMessage formats input', async () => {
26+
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
27+
const msg = DiscoverSkillsTool.renderToolUseMessage({
28+
description: 'deploy to cloudflare',
29+
})
30+
expect(msg).toContain('deploy to cloudflare')
31+
})
32+
33+
test('mapToolResultToToolResultBlockParam formats empty results', async () => {
34+
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
35+
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
36+
{ results: [], count: 0 },
37+
'test-id',
38+
)
39+
expect(result.content).toContain('No matching skills')
40+
})
41+
42+
test('mapToolResultToToolResultBlockParam formats results', async () => {
43+
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
44+
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
45+
{
46+
results: [{ name: 'test-skill', description: 'A test skill', score: 0.85 }],
47+
count: 1,
48+
},
49+
'test-id',
50+
)
51+
expect(result.content).toContain('test-skill')
52+
expect(result.content).toContain('0.85')
53+
})
54+
})
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
// Auto-generated stub — replace with real implementation
2-
export {};
3-
export const DISCOVER_SKILLS_TOOL_NAME: string = '';
1+
export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
2+
3+
export const DESCRIPTION =
4+
'Search for relevant skills by describing what you want to do'
5+
6+
export const DISCOVER_SKILLS_PROMPT = `Search for skills relevant to a task description. Returns matching skills ranked by relevance.
7+
8+
Use this when:
9+
- The auto-surfaced skills don't cover your current task
10+
- You're pivoting to a different kind of work mid-conversation
11+
- You want to find specialized skills for an unusual workflow
12+
13+
The search uses TF-IDF keyword matching against all registered skills (bundled, user-defined, and MCP-provided). Results include skill name, description, and relevance score.`
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { call } from '../skill-learning.js'
6+
import {
7+
recordSkillGap,
8+
saveInstinct,
9+
createInstinct,
10+
resolveProjectContext,
11+
} from '../../../services/skillLearning/index.js'
12+
13+
let root: string
14+
const originalEnv = { ...process.env }
15+
16+
beforeEach(() => {
17+
root = mkdtempSync(join(tmpdir(), 'skill-learning-command-'))
18+
process.env = { ...originalEnv }
19+
process.env.CLAUDE_SKILL_LEARNING_HOME = root
20+
process.env.CLAUDE_CONFIG_DIR = join(root, 'config')
21+
process.env.SKILL_LEARNING_ENABLED = '1'
22+
})
23+
24+
afterEach(() => {
25+
process.env = { ...originalEnv }
26+
rmSync(root, { recursive: true, force: true })
27+
})
28+
29+
describe('skill-learning command', () => {
30+
test('status reports observations and instincts', async () => {
31+
const result = await call('status', {} as any)
32+
33+
expect(result.type).toBe('text')
34+
if (result.type === 'text') {
35+
expect(result.value).toContain('Skill Learning status')
36+
expect(result.value).toContain('Observations: 0')
37+
}
38+
})
39+
40+
test('promote (no args) prints usage and candidate summary', async () => {
41+
const result = await call('promote', {} as any)
42+
43+
expect(result.type).toBe('text')
44+
if (result.type === 'text') {
45+
expect(result.value).toContain('Promotion candidates')
46+
expect(result.value).toContain('promote gap')
47+
expect(result.value).toContain('promote instinct')
48+
}
49+
})
50+
51+
test('promote gap <key> promotes a pending gap to draft', async () => {
52+
const project = resolveProjectContext(process.cwd())
53+
const gap = await recordSkillGap({
54+
prompt: 'refactor the api gateway',
55+
cwd: process.cwd(),
56+
project,
57+
rootDir: root,
58+
})
59+
expect(gap.status).toBe('pending')
60+
61+
const result = await call(`promote gap ${gap.key}`, {} as any)
62+
63+
expect(result.type).toBe('text')
64+
if (result.type === 'text') {
65+
expect(result.value).toContain('Promoted gap')
66+
expect(result.value).toContain('status=draft')
67+
}
68+
})
69+
70+
test('promote gap <unknown-key> reports not found', async () => {
71+
const result = await call('promote gap does-not-exist', {} as any)
72+
expect(result.type).toBe('text')
73+
if (result.type === 'text') {
74+
expect(result.value).toContain('No gap found')
75+
}
76+
})
77+
78+
test('promote instinct <id> copies a project instinct to global scope', async () => {
79+
const project = resolveProjectContext(process.cwd())
80+
const instinct = createInstinct({
81+
trigger: 'when committing',
82+
action: 'run tests first',
83+
confidence: 0.85,
84+
domain: 'testing',
85+
source: 'session-observation',
86+
scope: 'project',
87+
projectId: project.projectId,
88+
projectName: project.projectName,
89+
evidence: ['observed twice'],
90+
})
91+
await saveInstinct(instinct, { project, rootDir: root })
92+
93+
const result = await call(`promote instinct ${instinct.id}`, {} as any)
94+
95+
expect(result.type).toBe('text')
96+
if (result.type === 'text') {
97+
expect(result.value).toContain('Promoted instinct')
98+
expect(result.value).toContain('global scope')
99+
}
100+
})
101+
102+
test('projects lists known project scopes', async () => {
103+
// Resolving once registers the current project in the registry.
104+
resolveProjectContext(root)
105+
106+
const result = await call('projects', {} as any)
107+
108+
expect(result.type).toBe('text')
109+
if (result.type === 'text') {
110+
expect(
111+
result.value.includes('Known project scopes') ||
112+
result.value.includes('No known project scopes'),
113+
).toBe(true)
114+
}
115+
})
116+
117+
test('default help mentions promote and projects, no write-fixture', async () => {
118+
const result = await call('unknown-sub', {} as any)
119+
expect(result.type).toBe('text')
120+
if (result.type === 'text') {
121+
expect(result.value).toContain('promote')
122+
expect(result.value).toContain('projects')
123+
expect(result.value).not.toContain('write-fixture')
124+
}
125+
})
126+
127+
test('ingest imports transcript observations and instincts', async () => {
128+
const transcript = join(root, 'session.jsonl')
129+
writeFileSync(
130+
transcript,
131+
JSON.stringify({
132+
type: 'user',
133+
sessionId: 's1',
134+
cwd: root,
135+
message: { role: 'user', content: '不要 mock,用 testing-library' },
136+
}) + '\n',
137+
)
138+
139+
// Pass --min-session-length=0 so the 1-line test transcript is not skipped
140+
// by the ECC-parity gate (default threshold: 10 observations).
141+
const result = await call(
142+
`ingest ${transcript} --min-session-length=0`,
143+
{} as any,
144+
)
145+
146+
expect(result.type).toBe('text')
147+
if (result.type === 'text') {
148+
expect(result.value).toContain('Ingested')
149+
expect(result.value).toContain('saved 1 instincts')
150+
}
151+
})
152+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Command } from '../../commands.js'
2+
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js'
3+
4+
const skillLearning = {
5+
type: 'local-jsx',
6+
name: 'skill-learning',
7+
description: 'Manage skill learning (observe, analyze, evolve)',
8+
argumentHint:
9+
'[start|stop|about|status|ingest|evolve|export|import|prune|promote|projects]',
10+
isEnabled: () => isSkillLearningEnabled(),
11+
isHidden: false,
12+
load: () => import('./skillPanel.js'),
13+
} satisfies Command
14+
15+
export default skillLearning

0 commit comments

Comments
 (0)