feat: 添加 skill learning 技能学习闭环系统

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:09 +08:00
parent 04c7ed4250
commit 1837df5f88
64 changed files with 11009 additions and 36 deletions

View 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)
})
})

View 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)
})
})

View 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')
})
})

View 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()
})
})

View File

@@ -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
}

View 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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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'
}