mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
feat: 添加 skill learning 技能学习闭环系统
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
229
src/services/skillSearch/__tests__/intentNormalize.test.ts
Normal file
229
src/services/skillSearch/__tests__/intentNormalize.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Must mock queryHaiku before importing the module under test so the ESM
|
||||
// import binding picks up the stub.
|
||||
const haikuCalls: Array<{ systemPrompt: unknown; userPrompt: string }> = []
|
||||
let haikuResponder: (userPrompt: string) => Promise<unknown> = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'optimize code performance' }] },
|
||||
})
|
||||
|
||||
mock.module('../../api/claude.js', () => ({
|
||||
queryHaiku: mock(
|
||||
async (args: { systemPrompt: unknown; userPrompt: string }) => {
|
||||
haikuCalls.push({
|
||||
systemPrompt: args.systemPrompt,
|
||||
userPrompt: args.userPrompt,
|
||||
})
|
||||
return haikuResponder(args.userPrompt)
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
import {
|
||||
clearIntentNormalizeCache,
|
||||
isIntentNormalizeEnabled,
|
||||
normalizeQueryIntent,
|
||||
} from '../intentNormalize.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
haikuCalls.length = 0
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'optimize code performance' }] },
|
||||
})
|
||||
clearIntentNormalizeCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
clearIntentNormalizeCache()
|
||||
})
|
||||
|
||||
describe('isIntentNormalizeEnabled', () => {
|
||||
test('defaults to disabled when flag is unset', () => {
|
||||
delete process.env.SKILL_SEARCH_INTENT_ENABLED
|
||||
expect(isIntentNormalizeEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
test('enabled when flag is "1"', () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
expect(isIntentNormalizeEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
test('disabled for any value other than "1"', () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = 'true'
|
||||
expect(isIntentNormalizeEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — feature flag gating', () => {
|
||||
test('returns query unchanged when flag is off', async () => {
|
||||
delete process.env.SKILL_SEARCH_INTENT_ENABLED
|
||||
const result = await normalizeQueryIntent('帮我优化代码的性能')
|
||||
expect(result).toBe('帮我优化代码的性能')
|
||||
expect(haikuCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('returns empty string as-is without calling Haiku', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
const result = await normalizeQueryIntent('')
|
||||
expect(result).toBe('')
|
||||
expect(haikuCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('trims whitespace-only input to empty string', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
const result = await normalizeQueryIntent(' \n ')
|
||||
expect(result).toBe('')
|
||||
expect(haikuCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — ASCII fast path', () => {
|
||||
test('ASCII query bypasses Haiku and returns unchanged', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
const result = await normalizeQueryIntent('optimize code performance')
|
||||
expect(result).toBe('optimize code performance')
|
||||
expect(haikuCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('ASCII query with punctuation still bypasses Haiku', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
const result = await normalizeQueryIntent('audit feature flags for stubs')
|
||||
expect(result).toBe('audit feature flags for stubs')
|
||||
expect(haikuCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — CJK path calls Haiku', () => {
|
||||
test('CJK query concatenates keywords returned by Haiku', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'optimize code performance refactor' }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await normalizeQueryIntent('帮我优化代码的性能')
|
||||
|
||||
expect(haikuCalls.length).toBe(1)
|
||||
expect(result).toBe('帮我优化代码的性能 optimize code performance refactor')
|
||||
})
|
||||
|
||||
test('mixed CJK + ASCII query also calls Haiku', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'review code audit' }] },
|
||||
})
|
||||
const result = await normalizeQueryIntent('帮我做 code review')
|
||||
expect(haikuCalls.length).toBe(1)
|
||||
expect(result).toBe('帮我做 code review review code audit')
|
||||
})
|
||||
|
||||
test('Haiku output gets sanitized: lowercased, punctuation stripped', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Optimize, Code! Performance?' }],
|
||||
},
|
||||
})
|
||||
const result = await normalizeQueryIntent('优化代码')
|
||||
expect(result).toBe('优化代码 optimize code performance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — graceful fallback', () => {
|
||||
test('empty LLM response falls back to original query', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: '' }] },
|
||||
})
|
||||
const result = await normalizeQueryIntent('优化代码')
|
||||
expect(result).toBe('优化代码')
|
||||
expect(haikuCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('Haiku throwing an error falls back to original query', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => {
|
||||
throw new Error('network down')
|
||||
}
|
||||
const result = await normalizeQueryIntent('重构代码')
|
||||
expect(result).toBe('重构代码')
|
||||
expect(haikuCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('malformed LLM response (no text blocks) falls back', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({ message: { content: 'not-an-array' } })
|
||||
const result = await normalizeQueryIntent('优化代码')
|
||||
expect(result).toBe('优化代码')
|
||||
})
|
||||
|
||||
test('LLM responds with only punctuation -> sanitize empties it -> fallback', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: '!!!???' }] },
|
||||
})
|
||||
const result = await normalizeQueryIntent('优化代码')
|
||||
expect(result).toBe('优化代码')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — cache behavior', () => {
|
||||
test('repeat calls with same query use cache (only 1 Haiku call)', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'optimize code' }] },
|
||||
})
|
||||
|
||||
const a = await normalizeQueryIntent('帮我优化代码')
|
||||
const b = await normalizeQueryIntent('帮我优化代码')
|
||||
const c = await normalizeQueryIntent('帮我优化代码')
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(b).toBe(c)
|
||||
expect(haikuCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('different queries trigger separate Haiku calls', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async (userPrompt: string) => ({
|
||||
message: {
|
||||
content: [{ type: 'text', text: `kw-for-${userPrompt.slice(0, 2)}` }],
|
||||
},
|
||||
})
|
||||
|
||||
await normalizeQueryIntent('优化代码')
|
||||
await normalizeQueryIntent('重构模块')
|
||||
|
||||
expect(haikuCalls.length).toBe(2)
|
||||
})
|
||||
|
||||
test('clearIntentNormalizeCache resets the cache', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'kw' }] },
|
||||
})
|
||||
|
||||
await normalizeQueryIntent('优化代码')
|
||||
clearIntentNormalizeCache()
|
||||
await normalizeQueryIntent('优化代码')
|
||||
|
||||
expect(haikuCalls.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeQueryIntent — input capping', () => {
|
||||
test('very long CJK input is truncated to 500 chars before sending to Haiku', async () => {
|
||||
process.env.SKILL_SEARCH_INTENT_ENABLED = '1'
|
||||
const longInput = '优化代码'.repeat(300) // 1200 chars
|
||||
haikuResponder = async () => ({
|
||||
message: { content: [{ type: 'text', text: 'optimize code' }] },
|
||||
})
|
||||
await normalizeQueryIntent(longInput)
|
||||
expect(haikuCalls[0]?.userPrompt.length).toBeLessThanOrEqual(500)
|
||||
})
|
||||
})
|
||||
221
src/services/skillSearch/__tests__/localSearch.test.ts
Normal file
221
src/services/skillSearch/__tests__/localSearch.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
searchSkills,
|
||||
tokenize,
|
||||
tokenizeAndStem,
|
||||
type SkillIndexEntry,
|
||||
} from '../localSearch.js'
|
||||
|
||||
function makeEntry(overrides: Partial<SkillIndexEntry>): SkillIndexEntry {
|
||||
const tokens = overrides.tokens ?? []
|
||||
const tfVector = overrides.tfVector ?? buildTfVector(tokens)
|
||||
const name = overrides.name ?? 'test-skill'
|
||||
return {
|
||||
name,
|
||||
normalizedName:
|
||||
overrides.normalizedName ?? name.toLowerCase().replace(/[-_]/g, ' '),
|
||||
description: overrides.description ?? '',
|
||||
whenToUse: overrides.whenToUse,
|
||||
source: overrides.source ?? 'test',
|
||||
loadedFrom: overrides.loadedFrom,
|
||||
skillRoot: overrides.skillRoot,
|
||||
contentLength: overrides.contentLength,
|
||||
tokens,
|
||||
tfVector,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTfVector(tokens: string[]): Map<string, number> {
|
||||
const freq = new Map<string, number>()
|
||||
for (const t of tokens) freq.set(t, (freq.get(t) ?? 0) + 1)
|
||||
const max = Math.max(...freq.values(), 1)
|
||||
const tf = new Map<string, number>()
|
||||
for (const [term, count] of freq) tf.set(term, count / max)
|
||||
return tf
|
||||
}
|
||||
|
||||
describe('tokenize — CJK bi-gram + ASCII', () => {
|
||||
test('优化重构流程 produces five overlapping bi-grams', () => {
|
||||
const tokens = tokenize('优化重构流程')
|
||||
expect(tokens).toContain('优化')
|
||||
expect(tokens).toContain('化重')
|
||||
expect(tokens).toContain('重构')
|
||||
expect(tokens).toContain('构流')
|
||||
expect(tokens).toContain('流程')
|
||||
expect(tokens.length).toBe(5)
|
||||
})
|
||||
|
||||
test('pure ASCII input retains prior behaviour (regression)', () => {
|
||||
const tokens = tokenize('Refactor TypeScript helpers')
|
||||
expect(tokens).toContain('refactor')
|
||||
expect(tokens).toContain('typescript')
|
||||
expect(tokens).toContain('helpers')
|
||||
})
|
||||
|
||||
test('mixed Chinese + English is segmented on both sides', () => {
|
||||
const tokens = tokenize('优化 refactor 流程')
|
||||
expect(tokens).toContain('优化')
|
||||
expect(tokens).toContain('流程')
|
||||
expect(tokens).toContain('refactor')
|
||||
// Adjacent CJK segments are separated by ASCII content, so no cross-segment
|
||||
// bi-gram should appear.
|
||||
expect(tokens).not.toContain('化流')
|
||||
})
|
||||
|
||||
test('isolated single Chinese character produces no bi-gram', () => {
|
||||
const tokens = tokenize('优 is lonely')
|
||||
expect(tokens.some(t => /[\u4e00-\u9fff]/.test(t))).toBe(false)
|
||||
expect(tokens).toContain('lonely')
|
||||
})
|
||||
|
||||
test('ASCII stop words still filtered in mixed input', () => {
|
||||
const tokens = tokenize('the 优化 is fast')
|
||||
expect(tokens).not.toContain('the')
|
||||
expect(tokens).not.toContain('is')
|
||||
expect(tokens).toContain('优化')
|
||||
expect(tokens).toContain('fast')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokenizeAndStem — CJK passes through, ASCII stemmed', () => {
|
||||
test('CJK bi-grams are not stemmed', () => {
|
||||
const tokens = tokenizeAndStem('优化流程')
|
||||
expect(tokens).toContain('优化')
|
||||
expect(tokens).toContain('化流')
|
||||
expect(tokens).toContain('流程')
|
||||
})
|
||||
|
||||
test('ASCII words are stemmed while CJK survives', () => {
|
||||
const tokens = tokenizeAndStem('refactoring 重构 helpers')
|
||||
expect(tokens).toContain('refactor')
|
||||
expect(tokens).toContain('重构')
|
||||
expect(tokens).toContain('helper')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchSkills — CJK query against skill index', () => {
|
||||
test('Chinese query against Chinese-metadata skill produces positive score', () => {
|
||||
const chineseSkillTokens = tokenizeAndStem(
|
||||
'refactor-cleaner 清理 重构 流程 的工具',
|
||||
)
|
||||
const unrelatedTokens = tokenizeAndStem(
|
||||
'database-migration tool for schema upgrades',
|
||||
)
|
||||
const index: SkillIndexEntry[] = [
|
||||
makeEntry({
|
||||
name: 'refactor-cleaner',
|
||||
description: '清理和重构流程辅助',
|
||||
tokens: chineseSkillTokens,
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'database-migration',
|
||||
description: 'schema upgrade',
|
||||
tokens: unrelatedTokens,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = searchSkills('优化重构流程', index, 5)
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0]?.name).toBe('refactor-cleaner')
|
||||
expect(results[0]?.score).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('pure English query still ranks English skill first (regression)', () => {
|
||||
const refactorTokens = tokenizeAndStem(
|
||||
'refactor clean typescript code helper',
|
||||
)
|
||||
const unrelatedTokens = tokenizeAndStem(
|
||||
'security review audit vulnerabilities',
|
||||
)
|
||||
const index: SkillIndexEntry[] = [
|
||||
makeEntry({
|
||||
name: 'refactor-helper',
|
||||
description: 'refactor typescript',
|
||||
tokens: refactorTokens,
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'security-review',
|
||||
description: 'security audit',
|
||||
tokens: unrelatedTokens,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = searchSkills('refactor typescript', index, 5)
|
||||
|
||||
expect(results[0]?.name).toBe('refactor-helper')
|
||||
})
|
||||
|
||||
test('CJK query with only 1 matching bi-gram is filtered out (Proposal D)', () => {
|
||||
const promptOptTokens = tokenizeAndStem(
|
||||
'prompt-optimizer optimize prompts for better performance 当前最佳实践',
|
||||
)
|
||||
const otherTokens = tokenizeAndStem(
|
||||
'database-migration tool for schema upgrades',
|
||||
)
|
||||
const index: SkillIndexEntry[] = [
|
||||
makeEntry({
|
||||
name: 'prompt-optimizer',
|
||||
description: 'optimize prompts',
|
||||
tokens: promptOptTokens,
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'database-migration',
|
||||
description: 'schema upgrade',
|
||||
tokens: otherTokens,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = searchSkills('研究当前代码', index, 5)
|
||||
|
||||
expect(results.length).toBe(0)
|
||||
})
|
||||
|
||||
test('CJK query with 2+ matching bi-grams passes the gate', () => {
|
||||
const refactorTokens = tokenizeAndStem(
|
||||
'refactor-cleaner 代码重构 清理冗余代码',
|
||||
)
|
||||
const unrelatedTokens = tokenizeAndStem(
|
||||
'database-migration tool for schema upgrades',
|
||||
)
|
||||
const index: SkillIndexEntry[] = [
|
||||
makeEntry({
|
||||
name: 'refactor-cleaner',
|
||||
description: '代码重构清理',
|
||||
tokens: refactorTokens,
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'database-migration',
|
||||
description: 'schema upgrade',
|
||||
tokens: unrelatedTokens,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = searchSkills('重构代码', index, 5)
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0]?.name).toBe('refactor-cleaner')
|
||||
})
|
||||
|
||||
test('exact skill name in query boosts score (Proposal C)', () => {
|
||||
const codeReviewTokens = tokenizeAndStem('code-review review code quality')
|
||||
const securityTokens = tokenizeAndStem('security-review review security')
|
||||
const index: SkillIndexEntry[] = [
|
||||
makeEntry({
|
||||
name: 'code-review',
|
||||
description: 'review code quality',
|
||||
tokens: codeReviewTokens,
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'security-review',
|
||||
description: 'review security',
|
||||
tokens: securityTokens,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = searchSkills('code review', index, 5)
|
||||
|
||||
expect(results[0]?.name).toBe('code-review')
|
||||
expect(results[0]!.score).toBeGreaterThanOrEqual(0.75)
|
||||
})
|
||||
})
|
||||
123
src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
Normal file
123
src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractQueryFromMessages } from '../prefetch.js'
|
||||
import type { Message } from '../../../types/message.js'
|
||||
|
||||
function userText(text: string): Message {
|
||||
return { type: 'user', content: text } as unknown as Message
|
||||
}
|
||||
|
||||
function userTextBlocks(text: string): Message {
|
||||
return {
|
||||
type: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function userToolResult(id: string): Message {
|
||||
return {
|
||||
type: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: id, content: 'output' }],
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistantText(text: string): Message {
|
||||
return { type: 'assistant', content: text } as unknown as Message
|
||||
}
|
||||
|
||||
describe('extractQueryFromMessages — inter-turn穿透逻辑', () => {
|
||||
test('null input + messages末尾是tool_result → 穿透到真实user文本', () => {
|
||||
const messages: Message[] = [
|
||||
userText('研究当前代码'),
|
||||
assistantText('调用工具'),
|
||||
userToolResult('tool_01'),
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('研究当前代码')
|
||||
})
|
||||
|
||||
test('null input + messages末尾是text block形式的user → 正确提取', () => {
|
||||
const messages: Message[] = [
|
||||
userTextBlocks('refactor the auth module'),
|
||||
assistantText('thinking...'),
|
||||
userToolResult('tool_02'),
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('refactor the auth module')
|
||||
})
|
||||
|
||||
test('null input + 连续多轮tool_result → 继续向前找到最早的user文本', () => {
|
||||
const messages: Message[] = [
|
||||
userText('研究当前代码'),
|
||||
assistantText('第一次调用'),
|
||||
userToolResult('tool_a'),
|
||||
assistantText('第二次调用'),
|
||||
userToolResult('tool_b'),
|
||||
assistantText('第三次调用'),
|
||||
userToolResult('tool_c'),
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('研究当前代码')
|
||||
})
|
||||
|
||||
test('null input + 空messages → 空串', () => {
|
||||
const query = extractQueryFromMessages(null, [])
|
||||
expect(query).toBe('')
|
||||
})
|
||||
|
||||
test('null input + 全是tool_result (无真实文本) → 空串', () => {
|
||||
const messages: Message[] = [
|
||||
userToolResult('tool_a'),
|
||||
userToolResult('tool_b'),
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('')
|
||||
})
|
||||
|
||||
test('string input + null messages → 只返回input', () => {
|
||||
const query = extractQueryFromMessages('hello world', [])
|
||||
expect(query).toBe('hello world')
|
||||
})
|
||||
|
||||
test('string input + 有user文本 → 两者拼接', () => {
|
||||
const messages: Message[] = [userText('previous query')]
|
||||
const query = extractQueryFromMessages('new query', messages)
|
||||
expect(query).toContain('new query')
|
||||
expect(query).toContain('previous query')
|
||||
})
|
||||
|
||||
test('超长user文本被截断到500字', () => {
|
||||
const longText = 'a'.repeat(1000)
|
||||
const messages: Message[] = [userText(longText)]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query.length).toBe(500)
|
||||
})
|
||||
|
||||
test('tool_result里含text字段 (但type=tool_result) → 必须跳过,不能误用', () => {
|
||||
const messages: Message[] = [
|
||||
userText('real query'),
|
||||
{
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
text: 'this is tool output masquerading as text',
|
||||
},
|
||||
],
|
||||
} as unknown as Message,
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('real query')
|
||||
})
|
||||
|
||||
test('user content数组里text为空串 → 跳过空text继续找', () => {
|
||||
const messages: Message[] = [
|
||||
userText('real query'),
|
||||
{
|
||||
type: 'user',
|
||||
content: [{ type: 'text', text: ' ' }],
|
||||
} as unknown as Message,
|
||||
]
|
||||
const query = extractQueryFromMessages(null, messages)
|
||||
expect(query).toBe('real query')
|
||||
})
|
||||
})
|
||||
101
src/services/skillSearch/__tests__/prefetch.test.ts
Normal file
101
src/services/skillSearch/__tests__/prefetch.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { clearCommandsCache } from '../../../commands.js'
|
||||
import { getTurnZeroSkillDiscovery } from '../prefetch.js'
|
||||
import { clearSkillIndexCache } from '../localSearch.js'
|
||||
|
||||
let root: string
|
||||
let previousCwd: string
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'skill-search-prefetch-'))
|
||||
previousCwd = process.cwd()
|
||||
process.chdir(root)
|
||||
process.env = { ...originalEnv }
|
||||
process.env.CLAUDE_CONFIG_DIR = join(root, 'config')
|
||||
process.env.CLAUDE_SKILL_LEARNING_HOME = join(root, 'learning')
|
||||
process.env.SKILL_SEARCH_ENABLED = '1'
|
||||
process.env.SKILL_LEARNING_ENABLED = '1'
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key'
|
||||
clearCommandsCache()
|
||||
clearSkillIndexCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd)
|
||||
process.env = { ...originalEnv }
|
||||
clearCommandsCache()
|
||||
clearSkillIndexCache()
|
||||
try {
|
||||
rmSync(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
})
|
||||
} catch {
|
||||
// Windows can keep transient handles after dynamic command loading.
|
||||
}
|
||||
})
|
||||
|
||||
describe('skill search prefetch', () => {
|
||||
test('auto-loads high-confidence project skill content', async () => {
|
||||
const skillDir = join(root, '.claude', 'skills', 'feature-audit')
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: feature-audit',
|
||||
'description: Audit feature flags and classify minimal implementations',
|
||||
'---',
|
||||
'',
|
||||
'# Feature Audit',
|
||||
'',
|
||||
'Use the feature flag audit workflow and classify flags as stub, shell, MVP, or thin-toggle.',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const attachment = await getTurnZeroSkillDiscovery(
|
||||
'audit feature flags for minimal implementation stubs',
|
||||
[],
|
||||
{ agentId: undefined } as any,
|
||||
)
|
||||
|
||||
expect(attachment?.type).toBe('skill_discovery')
|
||||
if (attachment?.type !== 'skill_discovery') {
|
||||
throw new Error('expected skill_discovery attachment')
|
||||
}
|
||||
expect(attachment.skills[0]?.name).toBe('feature-audit')
|
||||
expect(attachment.skills[0]?.autoLoaded).toBe(true)
|
||||
expect(attachment.skills[0]?.content).toContain(
|
||||
'feature flag audit workflow',
|
||||
)
|
||||
})
|
||||
|
||||
test('records a pending skill gap on the first unmatched prompt (no draft file yet)', async () => {
|
||||
const attachment = await getTurnZeroSkillDiscovery(
|
||||
'frobnicate zephyr ledger workflow',
|
||||
[],
|
||||
{ agentId: undefined } as any,
|
||||
)
|
||||
|
||||
expect(attachment?.type).toBe('skill_discovery')
|
||||
if (attachment?.type !== 'skill_discovery') {
|
||||
throw new Error('expected skill_discovery attachment')
|
||||
}
|
||||
expect(attachment.skills).toEqual([])
|
||||
expect(attachment.gap?.status).toBe('pending')
|
||||
expect(attachment.gap?.draftPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isSkillSearchEnabled: () => boolean = () => false;
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
export function isSkillSearchEnabled(): boolean {
|
||||
if (process.env.SKILL_SEARCH_ENABLED === '0') return false
|
||||
if (process.env.SKILL_SEARCH_ENABLED === '1') return true
|
||||
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
149
src/services/skillSearch/intentNormalize.ts
Normal file
149
src/services/skillSearch/intentNormalize.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Intent Normalization Layer for Skill Search
|
||||
*
|
||||
* Problem: TF-IDF bag-of-words loses meaning when the user query is in Chinese
|
||||
* and most skill descriptions are English. CJK bi-grams get DF=1 (language
|
||||
* mismatch, not true rarity), producing IDF values that promote spurious
|
||||
* matches like `prompt-optimizer` for `帮我优化代码的性能`.
|
||||
*
|
||||
* Fix: Before handing the query to `searchSkills()`, ask Haiku to normalize it
|
||||
* into 3-6 English task/object keywords. Concatenate the normalized form with
|
||||
* the original so TF-IDF sees both — English keywords carry real matching
|
||||
* signal, the original text stays as a fallback.
|
||||
*
|
||||
* Design:
|
||||
* - Turn-zero only (blocking on user input): one Haiku call per session-unique
|
||||
* query. Not called in inter-turn prefetch (which repeats per tool loop).
|
||||
* - Process-level cache: identical queries within a session reuse the result.
|
||||
* - Graceful fallback: Haiku failure / timeout / empty → return original query.
|
||||
* - ASCII-only fast path: queries without CJK characters skip the LLM entirely.
|
||||
* - Feature-flagged: `SKILL_SEARCH_INTENT_ENABLED=1` to opt in.
|
||||
*/
|
||||
|
||||
import { queryHaiku } from '../api/claude.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
|
||||
const INTENT_SYSTEM_PROMPT = `You are a query normalizer for a skill-search index.
|
||||
|
||||
Given a user's natural-language request (often Chinese, possibly long), extract 3-6 English keywords that capture:
|
||||
1. TASK VERB (optimize, review, debug, refactor, test, deploy, analyze, write, audit, design, research, cleanup, implement)
|
||||
2. OBJECT (code, prompt, test, UI, API, database, documentation, performance, security, architecture)
|
||||
3. CONTEXT/DOMAIN when clear (frontend, backend, mobile, python, go, rust, typescript)
|
||||
|
||||
Output ONLY space-separated lowercase English keywords. No prose, no JSON, no punctuation, no code fences.
|
||||
|
||||
Examples:
|
||||
- "帮我优化代码的性能" -> optimize code performance refactor
|
||||
- "研究当前代码的实现然后分析优化思路" -> analyze code research refactor architecture
|
||||
- "优化 prompt 的表达" -> optimize prompt refine writing
|
||||
- "帮我做 code review" -> code review audit
|
||||
- "清理代码里的 TODO" -> cleanup refactor dead-code
|
||||
- "重构这个模块的代码" -> refactor code modularize
|
||||
- "帮我写个 Go 单元测试" -> write test golang unit
|
||||
|
||||
Output ONLY keywords. Nothing else.`
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 6_000
|
||||
const MAX_QUERY_CHARS = 500
|
||||
const MAX_KEYWORDS_CHARS = 120
|
||||
|
||||
/** Process-level cache. Keyed by the original (trimmed) query. */
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
export function isIntentNormalizeEnabled(): boolean {
|
||||
return process.env.SKILL_SEARCH_INTENT_ENABLED === '1'
|
||||
}
|
||||
|
||||
/** Only reset between tests. */
|
||||
export function clearIntentNormalizeCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user query so TF-IDF sees English task keywords.
|
||||
* Returns `<original> <keywords>` on success, or the original string on any
|
||||
* failure path. Never throws.
|
||||
*/
|
||||
export async function normalizeQueryIntent(query: string): Promise<string> {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (!isIntentNormalizeEnabled()) return trimmed
|
||||
|
||||
// ASCII-only queries are already in the right shape for the index.
|
||||
if (!/[\u4e00-\u9fff]/.test(trimmed)) return trimmed
|
||||
|
||||
const cached = cache.get(trimmed)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const capped = trimmed.slice(0, MAX_QUERY_CHARS)
|
||||
const keywords = await callHaiku(capped)
|
||||
const result = keywords ? `${trimmed} ${keywords}` : trimmed
|
||||
cache.set(trimmed, result)
|
||||
logForDebugging(
|
||||
`[skill-search] intent normalized: "${trimmed.slice(0, 40)}" -> "${keywords}"`,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async function callHaiku(query: string): Promise<string> {
|
||||
const timeoutMs = getTimeoutMs()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await queryHaiku({
|
||||
systemPrompt: asSystemPrompt([INTENT_SYSTEM_PROMPT]),
|
||||
userPrompt: query,
|
||||
signal: controller.signal,
|
||||
options: {
|
||||
querySource: 'skill_search_intent',
|
||||
enablePromptCaching: true,
|
||||
agents: [],
|
||||
isNonInteractiveSession: true,
|
||||
hasAppendSystemPrompt: false,
|
||||
mcpTools: [],
|
||||
},
|
||||
})
|
||||
const text = extractResponseText(response?.message?.content)
|
||||
return sanitizeKeywords(text)
|
||||
} catch (error) {
|
||||
logForDebugging(`[skill-search] intent normalize failed: ${error}`)
|
||||
return ''
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeoutMs(): number {
|
||||
const raw = process.env.SKILL_SEARCH_INTENT_TIMEOUT_MS
|
||||
if (!raw) return DEFAULT_TIMEOUT_MS
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS
|
||||
return parsed
|
||||
}
|
||||
|
||||
function extractResponseText(content: unknown): string {
|
||||
if (!Array.isArray(content)) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const record = block as Record<string, unknown>
|
||||
if (record.type !== 'text') continue
|
||||
if (typeof record.text === 'string') parts.push(record.text)
|
||||
}
|
||||
return parts.join('').trim()
|
||||
}
|
||||
|
||||
function sanitizeKeywords(raw: string): string {
|
||||
if (!raw) return ''
|
||||
// Strip anything that's not a keyword character. Keep ascii letters, digits,
|
||||
// hyphens, and spaces. Collapse whitespace.
|
||||
const cleaned = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\- ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!cleaned) return ''
|
||||
return cleaned.slice(0, MAX_KEYWORDS_CHARS)
|
||||
}
|
||||
@@ -1,3 +1,444 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const clearSkillIndexCache: () => void = () => {};
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
|
||||
export interface SkillIndexEntry {
|
||||
name: string
|
||||
normalizedName: string
|
||||
description: string
|
||||
whenToUse: string | undefined
|
||||
source: string
|
||||
loadedFrom: string | undefined
|
||||
skillRoot: string | undefined
|
||||
contentLength: number | undefined
|
||||
tokens: string[]
|
||||
tfVector: Map<string, number>
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
name: string
|
||||
description: string
|
||||
score: number
|
||||
shortId?: string
|
||||
source?: string
|
||||
loadedFrom?: string
|
||||
skillRoot?: string
|
||||
contentLength?: number
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'may',
|
||||
'might',
|
||||
'shall',
|
||||
'can',
|
||||
'need',
|
||||
'dare',
|
||||
'ought',
|
||||
'used',
|
||||
'to',
|
||||
'of',
|
||||
'in',
|
||||
'for',
|
||||
'on',
|
||||
'with',
|
||||
'at',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'further',
|
||||
'then',
|
||||
'once',
|
||||
'here',
|
||||
'there',
|
||||
'when',
|
||||
'where',
|
||||
'why',
|
||||
'how',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'no',
|
||||
'nor',
|
||||
'not',
|
||||
'only',
|
||||
'own',
|
||||
'same',
|
||||
'so',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'because',
|
||||
'but',
|
||||
'and',
|
||||
'or',
|
||||
'if',
|
||||
'while',
|
||||
'this',
|
||||
'that',
|
||||
'these',
|
||||
'those',
|
||||
'it',
|
||||
'its',
|
||||
'i',
|
||||
'me',
|
||||
'my',
|
||||
'we',
|
||||
'our',
|
||||
'you',
|
||||
'your',
|
||||
'he',
|
||||
'him',
|
||||
'his',
|
||||
'she',
|
||||
'her',
|
||||
'they',
|
||||
'them',
|
||||
'their',
|
||||
'what',
|
||||
'which',
|
||||
'who',
|
||||
'whom',
|
||||
'use',
|
||||
'using',
|
||||
'used',
|
||||
])
|
||||
|
||||
const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf]/
|
||||
|
||||
function isCjk(ch: string): boolean {
|
||||
return CJK_RANGE.test(ch)
|
||||
}
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens: string[] = []
|
||||
const lower = text.toLowerCase()
|
||||
let i = 0
|
||||
|
||||
while (i < lower.length) {
|
||||
if (isCjk(lower[i]!)) {
|
||||
let cjkRun = ''
|
||||
while (i < lower.length && isCjk(lower[i]!)) {
|
||||
cjkRun += lower[i]
|
||||
i++
|
||||
}
|
||||
for (let j = 0; j < cjkRun.length - 1; j++) {
|
||||
tokens.push(cjkRun.slice(j, j + 2))
|
||||
}
|
||||
} else if (/[a-z0-9]/.test(lower[i]!)) {
|
||||
let word = ''
|
||||
while (i < lower.length && /[a-z0-9\-_]/.test(lower[i]!)) {
|
||||
word += lower[i]
|
||||
i++
|
||||
}
|
||||
const cleaned = word.replace(/^[-_]+|[-_]+$/g, '')
|
||||
if (cleaned && !STOP_WORDS.has(cleaned)) {
|
||||
tokens.push(cleaned)
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function stem(word: string): string {
|
||||
if (isCjk(word[0] ?? '')) return word
|
||||
let s = word
|
||||
if (s.endsWith('ing') && s.length > 5) s = s.slice(0, -3)
|
||||
else if (s.endsWith('tion') && s.length > 5) s = s.slice(0, -4)
|
||||
else if (s.endsWith('ness') && s.length > 5) s = s.slice(0, -4)
|
||||
else if (s.endsWith('ment') && s.length > 5) s = s.slice(0, -4)
|
||||
else if (s.endsWith('ers') && s.length > 4) s = s.slice(0, -1)
|
||||
else if (s.endsWith('er') && s.length > 4) s = s.slice(0, -2)
|
||||
else if (s.endsWith('es') && s.length > 4) s = s.slice(0, -2)
|
||||
else if (s.endsWith('s') && s.length > 3 && !s.endsWith('ss'))
|
||||
s = s.slice(0, -1)
|
||||
else if (s.endsWith('ed') && s.length > 4) s = s.slice(0, -2)
|
||||
else if (s.endsWith('ly') && s.length > 4) s = s.slice(0, -2)
|
||||
return s
|
||||
}
|
||||
|
||||
export function tokenizeAndStem(text: string): string[] {
|
||||
return tokenize(text).map(stem)
|
||||
}
|
||||
|
||||
const FIELD_WEIGHT = {
|
||||
name: 3.0,
|
||||
whenToUse: 2.0,
|
||||
description: 1.0,
|
||||
allowedTools: 0.3,
|
||||
} as const
|
||||
|
||||
function computeWeightedTf(
|
||||
fields: { tokens: string[]; weight: number }[],
|
||||
): Map<string, number> {
|
||||
const weighted = new Map<string, number>()
|
||||
for (const field of fields) {
|
||||
const freq = new Map<string, number>()
|
||||
for (const t of field.tokens) freq.set(t, (freq.get(t) ?? 0) + 1)
|
||||
let max = 1
|
||||
for (const v of freq.values()) if (v > max) max = v
|
||||
for (const [term, count] of freq) {
|
||||
const val = (count / max) * field.weight
|
||||
const existing = weighted.get(term) ?? 0
|
||||
if (val > existing) weighted.set(term, val)
|
||||
}
|
||||
}
|
||||
return weighted
|
||||
}
|
||||
|
||||
function computeIdf(index: SkillIndexEntry[]): Map<string, number> {
|
||||
const df = new Map<string, number>()
|
||||
for (const entry of index) {
|
||||
const seen = new Set<string>()
|
||||
for (const t of entry.tokens) {
|
||||
if (!seen.has(t)) {
|
||||
df.set(t, (df.get(t) ?? 0) + 1)
|
||||
seen.add(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
const N = index.length
|
||||
const idf = new Map<string, number>()
|
||||
for (const [term, count] of df) {
|
||||
idf.set(term, Math.log(N / count))
|
||||
}
|
||||
return idf
|
||||
}
|
||||
|
||||
function cosineSimilarity(
|
||||
queryTfIdf: Map<string, number>,
|
||||
docTfIdf: Map<string, number>,
|
||||
): number {
|
||||
let dot = 0
|
||||
let normQ = 0
|
||||
let normD = 0
|
||||
|
||||
for (const [term, qWeight] of queryTfIdf) {
|
||||
const dWeight = docTfIdf.get(term) ?? 0
|
||||
dot += qWeight * dWeight
|
||||
normQ += qWeight * qWeight
|
||||
}
|
||||
for (const dWeight of docTfIdf.values()) {
|
||||
normD += dWeight * dWeight
|
||||
}
|
||||
|
||||
const denom = Math.sqrt(normQ) * Math.sqrt(normD)
|
||||
return denom === 0 ? 0 : dot / denom
|
||||
}
|
||||
|
||||
const DISPLAY_MIN_SCORE = Number(
|
||||
process.env.SKILL_SEARCH_DISPLAY_MIN_SCORE ?? '0.10',
|
||||
)
|
||||
const NAME_MATCH_BONUS = 0.4
|
||||
const NAME_MATCH_MIN_LENGTH = 4
|
||||
const CJK_MIN_BIGRAM_MATCHES = 2
|
||||
|
||||
function normalizeSkillName(name: string): string {
|
||||
return name.toLowerCase().replace(/[-_]/g, ' ')
|
||||
}
|
||||
|
||||
function splitHyphenatedName(name: string): string[] {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.split(/[-_]/)
|
||||
.filter(p => p.length >= 3)
|
||||
}
|
||||
|
||||
let cachedIndex: SkillIndexEntry[] | null = null
|
||||
let cachedIdf: Map<string, number> | null = null
|
||||
let cachedCwd: string | null = null
|
||||
|
||||
export function clearSkillIndexCache(): void {
|
||||
cachedIndex = null
|
||||
cachedIdf = null
|
||||
cachedCwd = null
|
||||
logForDebugging('[skill-search] index cache cleared')
|
||||
}
|
||||
|
||||
export async function getSkillIndex(cwd: string): Promise<SkillIndexEntry[]> {
|
||||
if (cachedIndex && cachedCwd === cwd) return cachedIndex
|
||||
|
||||
const { getCommands } = await import('../../commands.js')
|
||||
const commands = await getCommands(cwd)
|
||||
|
||||
const entries: SkillIndexEntry[] = []
|
||||
for (const cmd of commands) {
|
||||
if ((cmd as Record<string, unknown>).type !== 'prompt') continue
|
||||
if ((cmd as Record<string, unknown>).disableModelInvocation) continue
|
||||
|
||||
const name = cmd.name
|
||||
const description = cmd.description ?? ''
|
||||
const whenToUse = (cmd as Record<string, unknown>).whenToUse as
|
||||
| string
|
||||
| undefined
|
||||
const allowedTools =
|
||||
(
|
||||
(cmd as Record<string, unknown>).allowedTools as string[] | undefined
|
||||
)?.join(' ') ?? ''
|
||||
|
||||
const nameTokens = tokenizeAndStem(name)
|
||||
const nameParts = splitHyphenatedName(name)
|
||||
const nameWithParts = [
|
||||
...nameTokens,
|
||||
...nameParts.map(stem).filter(t => !STOP_WORDS.has(t)),
|
||||
]
|
||||
|
||||
const descTokens = tokenizeAndStem(description)
|
||||
const whenTokens = tokenizeAndStem(whenToUse ?? '')
|
||||
const toolsTokens = tokenizeAndStem(allowedTools)
|
||||
|
||||
const allTokens = [
|
||||
...new Set([
|
||||
...nameWithParts,
|
||||
...descTokens,
|
||||
...whenTokens,
|
||||
...toolsTokens,
|
||||
]),
|
||||
]
|
||||
|
||||
const tfVector = computeWeightedTf([
|
||||
{ tokens: nameWithParts, weight: FIELD_WEIGHT.name },
|
||||
{ tokens: whenTokens, weight: FIELD_WEIGHT.whenToUse },
|
||||
{ tokens: descTokens, weight: FIELD_WEIGHT.description },
|
||||
{ tokens: toolsTokens, weight: FIELD_WEIGHT.allowedTools },
|
||||
])
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
normalizedName: normalizeSkillName(name),
|
||||
description,
|
||||
whenToUse,
|
||||
source: ((cmd as Record<string, unknown>).source as string) ?? 'unknown',
|
||||
loadedFrom: (cmd as Record<string, unknown>).loadedFrom as
|
||||
| string
|
||||
| undefined,
|
||||
skillRoot: (cmd as Record<string, unknown>).skillRoot as
|
||||
| string
|
||||
| undefined,
|
||||
contentLength: (cmd as Record<string, unknown>).contentLength as
|
||||
| number
|
||||
| undefined,
|
||||
tokens: allTokens,
|
||||
tfVector,
|
||||
})
|
||||
}
|
||||
|
||||
const idf = computeIdf(entries)
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const [term, tf] of entry.tfVector) {
|
||||
entry.tfVector.set(term, tf * (idf.get(term) ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
cachedIndex = entries
|
||||
cachedIdf = idf
|
||||
cachedCwd = cwd
|
||||
logForDebugging(
|
||||
`[skill-search] indexed ${entries.length} skills from ${commands.length} commands`,
|
||||
)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function searchSkills(
|
||||
query: string,
|
||||
index: SkillIndexEntry[],
|
||||
limit = 5,
|
||||
): SearchResult[] {
|
||||
if (index.length === 0 || !query.trim()) return []
|
||||
|
||||
const queryTokens = tokenizeAndStem(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
|
||||
const queryTf = new Map<string, number>()
|
||||
const freq = new Map<string, number>()
|
||||
for (const t of queryTokens) freq.set(t, (freq.get(t) ?? 0) + 1)
|
||||
let max = 1
|
||||
for (const v of freq.values()) if (v > max) max = v
|
||||
for (const [term, count] of freq) queryTf.set(term, count / max)
|
||||
|
||||
const idf = cachedIdf ?? computeIdf(index)
|
||||
const queryTfIdf = new Map<string, number>()
|
||||
for (const [term, tf] of queryTf) {
|
||||
queryTfIdf.set(term, tf * (idf.get(term) ?? 0))
|
||||
}
|
||||
|
||||
const queryCjkTokens = queryTokens.filter(t => isCjk(t[0] ?? ''))
|
||||
const queryAsciiTokens = queryTokens.filter(t => !isCjk(t[0] ?? ''))
|
||||
const queryLower = query.toLowerCase().replace(/[-_]/g, ' ')
|
||||
|
||||
const results: SearchResult[] = []
|
||||
for (const entry of index) {
|
||||
let score = cosineSimilarity(queryTfIdf, entry.tfVector)
|
||||
|
||||
if (queryCjkTokens.length > 0 && score > 0) {
|
||||
const matchingCjk = queryCjkTokens.filter(t => entry.tfVector.has(t))
|
||||
if (matchingCjk.length < CJK_MIN_BIGRAM_MATCHES) {
|
||||
const hasAsciiMatch = queryAsciiTokens.some(t => entry.tfVector.has(t))
|
||||
if (!hasAsciiMatch) score = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.name.length >= NAME_MATCH_MIN_LENGTH) {
|
||||
if (queryLower.includes(entry.normalizedName)) {
|
||||
score = Math.max(score, 0.75)
|
||||
}
|
||||
}
|
||||
|
||||
if (score >= DISPLAY_MIN_SCORE) {
|
||||
results.push({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
score,
|
||||
source: entry.source,
|
||||
loadedFrom: entry.loadedFrom,
|
||||
skillRoot: entry.skillRoot,
|
||||
contentLength: entry.contentLength,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results.slice(0, limit)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,328 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Attachment } from '../../utils/attachments.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { DiscoverySignal } from './signals.js'
|
||||
import { isSkillSearchEnabled } from './featureCheck.js'
|
||||
import {
|
||||
getSkillIndex,
|
||||
searchSkills,
|
||||
type SearchResult,
|
||||
} from './localSearch.js'
|
||||
import { normalizeQueryIntent } from './intentNormalize.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
|
||||
export const startSkillDiscoveryPrefetch: (
|
||||
const discoveredThisSession = new Set<string>()
|
||||
const recordedGapSignals = new Set<string>()
|
||||
|
||||
const AUTO_LOAD_MIN_SCORE = Number(
|
||||
process.env.SKILL_SEARCH_AUTOLOAD_MIN_SCORE ?? '0.30',
|
||||
)
|
||||
const AUTO_LOAD_LIMIT = Number(process.env.SKILL_SEARCH_AUTOLOAD_LIMIT ?? '2')
|
||||
const AUTO_LOAD_MAX_CHARS = Number(
|
||||
process.env.SKILL_SEARCH_AUTOLOAD_MAX_CHARS ?? '12000',
|
||||
)
|
||||
|
||||
export function extractQueryFromMessages(
|
||||
input: string | null,
|
||||
messages: Message[],
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (input) parts.push(input)
|
||||
|
||||
// Walk backward. In inter-turn prefetch the most recent 'user' message is
|
||||
// typically a tool_result (no text block), so we must keep walking until we
|
||||
// find a real user utterance with string content or a text block.
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i] as Record<string, unknown>
|
||||
if (msg.type !== 'user') continue
|
||||
const content = msg.content
|
||||
if (typeof content === 'string') {
|
||||
parts.push(content.slice(0, 500))
|
||||
break
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
let foundText = false
|
||||
for (const block of content) {
|
||||
const entry = block as Record<string, unknown>
|
||||
// Skip tool_result and other non-text blocks — they carry no discovery
|
||||
// signal and would return undefined here regardless.
|
||||
if (entry.type && entry.type !== 'text') continue
|
||||
const text = entry.text
|
||||
if (typeof text === 'string' && text.trim()) {
|
||||
parts.push(text.slice(0, 500))
|
||||
foundText = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundText) break
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function buildDiscoveryAttachment(
|
||||
skills: SkillDiscoveryResult[],
|
||||
signal: DiscoverySignal,
|
||||
gap?: SkillDiscoveryGap,
|
||||
): Attachment {
|
||||
return {
|
||||
type: 'skill_discovery',
|
||||
skills,
|
||||
signal,
|
||||
source: 'native',
|
||||
gap,
|
||||
} as Attachment
|
||||
}
|
||||
|
||||
type SkillDiscoveryResult = {
|
||||
name: string
|
||||
description: string
|
||||
shortId?: string
|
||||
score?: number
|
||||
autoLoaded?: boolean
|
||||
content?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
type SkillDiscoveryGap = {
|
||||
key: string
|
||||
status: 'pending' | 'draft' | 'active'
|
||||
draftName?: string
|
||||
draftPath?: string
|
||||
activeName?: string
|
||||
activePath?: string
|
||||
}
|
||||
|
||||
async function enrichResultsForAutoLoad(
|
||||
results: SearchResult[],
|
||||
context: ToolUseContext,
|
||||
): Promise<SkillDiscoveryResult[]> {
|
||||
let loadedCount = 0
|
||||
const enriched: SkillDiscoveryResult[] = []
|
||||
|
||||
for (const result of results) {
|
||||
const base: SkillDiscoveryResult = {
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
score: result.score,
|
||||
}
|
||||
|
||||
if (loadedCount >= AUTO_LOAD_LIMIT || result.score < AUTO_LOAD_MIN_SCORE) {
|
||||
enriched.push(base)
|
||||
continue
|
||||
}
|
||||
|
||||
const loaded = await loadSkillContent(result)
|
||||
if (!loaded) {
|
||||
enriched.push(base)
|
||||
continue
|
||||
}
|
||||
|
||||
loadedCount++
|
||||
await markAutoLoadedSkill(result.name, loaded.path, loaded.content, context)
|
||||
enriched.push({
|
||||
...base,
|
||||
autoLoaded: true,
|
||||
content: loaded.content,
|
||||
path: loaded.path,
|
||||
})
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
async function loadSkillContent(
|
||||
result: SearchResult,
|
||||
): Promise<{ path: string; content: string } | null> {
|
||||
if (!result.skillRoot) return null
|
||||
|
||||
const candidates = [
|
||||
join(result.skillRoot, 'SKILL.md'),
|
||||
join(result.skillRoot, 'skill.md'),
|
||||
]
|
||||
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
return {
|
||||
path,
|
||||
content: parseFrontmatter(raw).content.slice(0, AUTO_LOAD_MAX_CHARS),
|
||||
}
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function markAutoLoadedSkill(
|
||||
name: string,
|
||||
path: string,
|
||||
content: string,
|
||||
context: ToolUseContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { addInvokedSkill } = await import('../../bootstrap/state.js')
|
||||
addInvokedSkill(name, path, content, context.agentId ?? null)
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRecordSkillGap(
|
||||
queryText: string,
|
||||
results: SearchResult[],
|
||||
context: ToolUseContext,
|
||||
trigger: DiscoverySignal['trigger'],
|
||||
): Promise<SkillDiscoveryGap | undefined> {
|
||||
if (trigger !== 'user_input') return undefined
|
||||
if (!queryText.trim()) return undefined
|
||||
|
||||
const gapSignalKey = `${trigger}:${queryText.trim().toLowerCase()}`
|
||||
if (recordedGapSignals.has(gapSignalKey)) return undefined
|
||||
recordedGapSignals.add(gapSignalKey)
|
||||
|
||||
try {
|
||||
const [{ isSkillLearningEnabled }, { recordSkillGap }] = await Promise.all([
|
||||
import('../skillLearning/featureCheck.js'),
|
||||
import('../skillLearning/skillGapStore.js'),
|
||||
])
|
||||
if (!isSkillLearningEnabled()) return undefined
|
||||
const gap = await recordSkillGap({
|
||||
prompt: queryText,
|
||||
cwd:
|
||||
((context as Record<string, unknown>).cwd as string) ?? process.cwd(),
|
||||
sessionId:
|
||||
((context as Record<string, unknown>).sessionId as string) ??
|
||||
'unknown-session',
|
||||
recommendations: results,
|
||||
})
|
||||
const status = gap.status
|
||||
if (status !== 'pending' && status !== 'draft' && status !== 'active') {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
key: gap.key,
|
||||
status,
|
||||
draftName: gap.draft?.name,
|
||||
draftPath: gap.draft?.skillPath,
|
||||
activeName: gap.active?.name,
|
||||
activePath: gap.active?.skillPath,
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(`[skill-search] skill gap learning error: ${error}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSkillDiscoveryPrefetch(
|
||||
input: string | null,
|
||||
messages: Message[],
|
||||
toolUseContext: ToolUseContext,
|
||||
) => Promise<Attachment[]> = (async () => []);
|
||||
export const collectSkillDiscoveryPrefetch: (
|
||||
): Promise<Attachment[]> {
|
||||
if (!isSkillSearchEnabled()) return []
|
||||
|
||||
const startedAt = Date.now()
|
||||
const queryText = extractQueryFromMessages(input, messages)
|
||||
if (!queryText.trim()) return []
|
||||
|
||||
try {
|
||||
const cwd =
|
||||
((toolUseContext as Record<string, unknown>).cwd as string) ??
|
||||
process.cwd()
|
||||
const index = await getSkillIndex(cwd)
|
||||
const results = searchSkills(queryText, index)
|
||||
|
||||
const newResults = results.filter(r => !discoveredThisSession.has(r.name))
|
||||
if (newResults.length === 0) return []
|
||||
|
||||
for (const r of newResults) discoveredThisSession.add(r.name)
|
||||
|
||||
const signal: DiscoverySignal = {
|
||||
trigger: 'assistant_turn',
|
||||
queryText: queryText.slice(0, 200),
|
||||
startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
indexSize: index.length,
|
||||
method: 'tfidf',
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[skill-search] prefetch found ${newResults.length} skills in ${signal.durationMs}ms`,
|
||||
)
|
||||
|
||||
return [
|
||||
buildDiscoveryAttachment(
|
||||
await enrichResultsForAutoLoad(newResults, toolUseContext),
|
||||
signal,
|
||||
),
|
||||
]
|
||||
} catch (error) {
|
||||
logForDebugging(`[skill-search] prefetch error: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectSkillDiscoveryPrefetch(
|
||||
pending: Promise<Attachment[]>,
|
||||
) => Promise<Attachment[]> = (async (pending) => pending);
|
||||
export const getTurnZeroSkillDiscovery: (
|
||||
): Promise<Attachment[]> {
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTurnZeroSkillDiscovery(
|
||||
input: string,
|
||||
messages: Message[],
|
||||
context: ToolUseContext,
|
||||
) => Promise<Attachment | null> = (async () => null);
|
||||
): Promise<Attachment | null> {
|
||||
if (!isSkillSearchEnabled()) return null
|
||||
if (!input.trim()) return null
|
||||
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const cwd =
|
||||
((context as Record<string, unknown>).cwd as string) ?? process.cwd()
|
||||
const index = await getSkillIndex(cwd)
|
||||
// Intent normalization (feature-flagged, ASCII-only fast path, graceful
|
||||
// fallback to original). Turn-zero is the one blocking entry — acceptable
|
||||
// to add a Haiku call here since a bad match here pollutes the LLM's
|
||||
// context for the entire session.
|
||||
const searchQuery = await normalizeQueryIntent(input)
|
||||
const results = searchSkills(searchQuery, index)
|
||||
const enriched = await enrichResultsForAutoLoad(results, context)
|
||||
const gap = enriched.some(result => result.autoLoaded)
|
||||
? undefined
|
||||
: await maybeRecordSkillGap(input, results, context, 'user_input')
|
||||
|
||||
if (results.length === 0 && !gap) return null
|
||||
|
||||
for (const r of results) discoveredThisSession.add(r.name)
|
||||
|
||||
const signal: DiscoverySignal = {
|
||||
trigger: 'user_input',
|
||||
queryText: input.slice(0, 200),
|
||||
startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
indexSize: index.length,
|
||||
method: 'tfidf',
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[skill-search] turn-zero found ${results.length} skills in ${signal.durationMs}ms`,
|
||||
)
|
||||
|
||||
return buildDiscoveryAttachment(enriched, signal, gap)
|
||||
} catch (error) {
|
||||
logForDebugging(`[skill-search] turn-zero error: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type DiscoverySignal = any;
|
||||
export interface DiscoverySignal {
|
||||
trigger: 'user_input' | 'assistant_turn' | 'tool_call'
|
||||
queryText: string
|
||||
startedAt: number
|
||||
durationMs: number
|
||||
indexSize: number
|
||||
method: 'tfidf' | 'keyword'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user