chore: remove skillLearning subsystem — unused, incompatible with CCP telemetry approach
This commit is contained in:
parent
d04846f5b8
commit
75fa212759
|
|
@ -1,36 +0,0 @@
|
||||||
import { feature } from 'bun:bundle'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build-time presence check: is the `/skill-learning` slash command
|
|
||||||
* compiled into this build? Used by the command registry's `isEnabled` so
|
|
||||||
* the command appears in the menu whenever it is buildable. Operators
|
|
||||||
* activate the subsystem itself via `/skill-learning start`, which flips
|
|
||||||
* `SKILL_LEARNING_ENABLED=1` and turns the runtime observers on (see
|
|
||||||
* `isSkillLearningEnabled`).
|
|
||||||
*/
|
|
||||||
export function isSkillLearningCompiledIn(): boolean {
|
|
||||||
if (feature('SKILL_LEARNING')) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runtime activation check: is the skill-learning subsystem actively
|
|
||||||
* running (toolEvent, runtime, session observers attached, persisting
|
|
||||||
* observations to disk)? Off by default — the operator must run
|
|
||||||
* `/skill-learning start` (which sets `SKILL_LEARNING_ENABLED=1`).
|
|
||||||
*
|
|
||||||
* Legacy `FEATURE_SKILL_LEARNING=1` is also accepted for backward
|
|
||||||
* compatibility with operators who set it before the slash-command UX
|
|
||||||
* landed.
|
|
||||||
*
|
|
||||||
* Build-flag gating is intentionally NOT performed here: the command
|
|
||||||
* registry already gates command compilation on the build flag, and this
|
|
||||||
* function is only reached from code paths that the build flag has
|
|
||||||
* already let through. Decoupling keeps the test surface clean (tests
|
|
||||||
* exercise the env-var contract without needing to mock `bun:bundle`).
|
|
||||||
*/
|
|
||||||
export function isSkillLearningEnabled(): boolean {
|
|
||||||
if (process.env.SKILL_LEARNING_ENABLED === '1') return true
|
|
||||||
if (process.env.FEATURE_SKILL_LEARNING === '1') return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
export interface StoredInstinct {
|
|
||||||
key: string
|
|
||||||
prompt: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInstinct(
|
|
||||||
prompt: string,
|
|
||||||
_options?: { sessionId?: string; projectId?: string },
|
|
||||||
): StoredInstinct {
|
|
||||||
return {
|
|
||||||
key: `instinct-${Buffer.from(prompt).toString('base64').slice(0, 32)}`,
|
|
||||||
prompt,
|
|
||||||
count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { join } from 'node:path'
|
|
||||||
|
|
||||||
export function getProjectStorageDir(cwd: string): string {
|
|
||||||
return join(cwd, '.claude')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveProjectContext(
|
|
||||||
cwd: string,
|
|
||||||
): { projectId: string; projectName: string } | null {
|
|
||||||
return {
|
|
||||||
projectId: cwd.replace(/[^a-zA-Z0-9]/g, '_'),
|
|
||||||
projectName: cwd.split('/').pop() ?? 'unknown',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
import { existsSync } from 'node:fs'
|
|
||||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
||||||
import { createHash } from 'node:crypto'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import type { SearchResult } from '../skillSearch/localSearch.js'
|
|
||||||
import { createInstinct, type StoredInstinct } from './instinctParser.js'
|
|
||||||
import {
|
|
||||||
getProjectStorageDir,
|
|
||||||
resolveProjectContext,
|
|
||||||
} from './projectContext.js'
|
|
||||||
import { generateSkillDraft, writeLearnedSkill } from './skillGenerator.js'
|
|
||||||
import type {
|
|
||||||
InstinctDomain,
|
|
||||||
SkillGapStatus,
|
|
||||||
SkillLearningProjectContext,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export type SkillGapRecommendation = Pick<
|
|
||||||
SearchResult,
|
|
||||||
'name' | 'description' | 'score'
|
|
||||||
>
|
|
||||||
|
|
||||||
export type SkillGapMaterialization =
|
|
||||||
| {
|
|
||||||
type: 'draft'
|
|
||||||
name: string
|
|
||||||
skillPath: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'active'
|
|
||||||
name: string
|
|
||||||
skillPath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SkillGapRecord = {
|
|
||||||
key: string
|
|
||||||
prompt: string
|
|
||||||
count: number
|
|
||||||
draftHits: number
|
|
||||||
// Session IDs that have already contributed a draft hit for this gap —
|
|
||||||
// prevents one session from inflating `draftHits` beyond 1 and flipping the
|
|
||||||
// `draftHits >= 2` active-promotion gate by itself.
|
|
||||||
draftHitSessions: string[]
|
|
||||||
status: SkillGapStatus
|
|
||||||
sessionId: string
|
|
||||||
cwd: string
|
|
||||||
projectId: string
|
|
||||||
projectName: string
|
|
||||||
recommendations: SkillGapRecommendation[]
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
draft?: SkillGapMaterialization
|
|
||||||
active?: SkillGapMaterialization
|
|
||||||
}
|
|
||||||
|
|
||||||
// P0-2 hook: when outcome-aware observation lands, augment this with a
|
|
||||||
// lookup into observationStore for a matching `outcome: 'success'` tool_complete
|
|
||||||
// observation keyed by (sessionId, gap.key). Until then, draft promotion uses
|
|
||||||
// count/signal only.
|
|
||||||
const DRAFT_PROMOTION_COUNT = 2
|
|
||||||
const ACTIVE_PROMOTION_COUNT = 4
|
|
||||||
const ACTIVE_PROMOTION_DRAFT_HITS = 2
|
|
||||||
|
|
||||||
type SkillGapState = {
|
|
||||||
version: 1
|
|
||||||
gaps: Record<string, SkillGapRecord>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RecordSkillGapOptions = {
|
|
||||||
prompt: string
|
|
||||||
cwd?: string
|
|
||||||
sessionId?: string
|
|
||||||
recommendations?: SearchResult[]
|
|
||||||
project?: SkillLearningProjectContext
|
|
||||||
rootDir?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recordSkillGap(
|
|
||||||
options: RecordSkillGapOptions,
|
|
||||||
): Promise<SkillGapRecord> {
|
|
||||||
const prompt = options.prompt.trim()
|
|
||||||
if (!prompt) {
|
|
||||||
throw new Error('Cannot record an empty skill gap')
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = options.project ?? resolveProjectContext(options.cwd)
|
|
||||||
const state = await readSkillGapState(project, options.rootDir)
|
|
||||||
const key = buildSkillGapKey(prompt)
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const existing = state.gaps[key]
|
|
||||||
|
|
||||||
const gap: SkillGapRecord = {
|
|
||||||
key,
|
|
||||||
prompt,
|
|
||||||
count: (existing?.count ?? 0) + 1,
|
|
||||||
draftHits: existing?.draftHits ?? 0,
|
|
||||||
draftHitSessions: existing?.draftHitSessions ?? [],
|
|
||||||
status: existing?.status ?? 'pending',
|
|
||||||
sessionId: options.sessionId ?? 'unknown-session',
|
|
||||||
cwd: options.cwd ?? project.cwd,
|
|
||||||
projectId: project.projectId,
|
|
||||||
projectName: project.projectName,
|
|
||||||
recommendations: (options.recommendations ?? []).slice(0, 5).map(r => ({
|
|
||||||
name: r.name,
|
|
||||||
description: r.description,
|
|
||||||
score: r.score,
|
|
||||||
})),
|
|
||||||
createdAt: existing?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
|
||||||
draft: existing?.draft,
|
|
||||||
active: existing?.active,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gap.status === 'rejected') {
|
|
||||||
state.gaps[key] = gap
|
|
||||||
await writeSkillGapState(project, state, options.rootDir)
|
|
||||||
return gap
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gap.draft && shouldPromoteToDraft(gap)) {
|
|
||||||
gap.draft = await writeSkillGapDraft(gap, project)
|
|
||||||
gap.status = 'draft'
|
|
||||||
await clearRuntimeSkillCaches()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gap.draft && !gap.active && shouldPromoteToActive(gap)) {
|
|
||||||
gap.active = await writeActiveSkillForGap(gap, project)
|
|
||||||
gap.status = 'active'
|
|
||||||
await clearRuntimeSkillCaches()
|
|
||||||
}
|
|
||||||
|
|
||||||
state.gaps[key] = gap
|
|
||||||
await writeSkillGapState(project, state, options.rootDir)
|
|
||||||
return gap
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readSkillGaps(
|
|
||||||
project = resolveProjectContext(),
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<SkillGapRecord[]> {
|
|
||||||
const state = await readSkillGapState(project, rootDir)
|
|
||||||
return Object.values(state.gaps).sort((a, b) => a.key.localeCompare(b.key))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findGapKeyByDraftPath(
|
|
||||||
draftPath: string,
|
|
||||||
project = resolveProjectContext(),
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const state = await readSkillGapState(project, rootDir)
|
|
||||||
for (const gap of Object.values(state.gaps)) {
|
|
||||||
if (gap.draft?.skillPath === draftPath) return gap.key
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recordDraftHit(
|
|
||||||
key: string,
|
|
||||||
project = resolveProjectContext(),
|
|
||||||
rootDir?: string,
|
|
||||||
sessionId = 'unknown-session',
|
|
||||||
): Promise<SkillGapRecord | undefined> {
|
|
||||||
const state = await readSkillGapState(project, rootDir)
|
|
||||||
const gap = state.gaps[key]
|
|
||||||
if (!gap || !gap.draft || gap.active) return gap
|
|
||||||
// One draft hit per session: a single actor reloading the same draft
|
|
||||||
// repeatedly must not flip the draftHits>=2 gate.
|
|
||||||
const existingSessions = gap.draftHitSessions ?? []
|
|
||||||
if (existingSessions.includes(sessionId)) return gap
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const updated: SkillGapRecord = {
|
|
||||||
...gap,
|
|
||||||
draftHits: gap.draftHits + 1,
|
|
||||||
draftHitSessions: [...existingSessions, sessionId],
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldPromoteToActive(updated)) {
|
|
||||||
updated.active = await writeActiveSkillForGap(updated, project)
|
|
||||||
updated.status = 'active'
|
|
||||||
await clearRuntimeSkillCaches()
|
|
||||||
}
|
|
||||||
|
|
||||||
state.gaps[key] = updated
|
|
||||||
await writeSkillGapState(project, state, rootDir)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function promoteGapToDraft(
|
|
||||||
key: string,
|
|
||||||
project = resolveProjectContext(),
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<SkillGapRecord | undefined> {
|
|
||||||
const state = await readSkillGapState(project, rootDir)
|
|
||||||
const gap = state.gaps[key]
|
|
||||||
if (!gap) return undefined
|
|
||||||
if (gap.status === 'rejected') return gap
|
|
||||||
if (gap.draft) return gap
|
|
||||||
const updated: SkillGapRecord = {
|
|
||||||
...gap,
|
|
||||||
draft: await writeSkillGapDraft(gap, project),
|
|
||||||
status: 'draft',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
state.gaps[key] = updated
|
|
||||||
await writeSkillGapState(project, state, rootDir)
|
|
||||||
await clearRuntimeSkillCaches()
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rejectSkillGap(
|
|
||||||
key: string,
|
|
||||||
project = resolveProjectContext(),
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<SkillGapRecord | undefined> {
|
|
||||||
const state = await readSkillGapState(project, rootDir)
|
|
||||||
const gap = state.gaps[key]
|
|
||||||
if (!gap) return undefined
|
|
||||||
const updated: SkillGapRecord = {
|
|
||||||
...gap,
|
|
||||||
status: 'rejected',
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
state.gaps[key] = updated
|
|
||||||
await writeSkillGapState(project, state, rootDir)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldPromoteToDraft(gap: SkillGapRecord): boolean {
|
|
||||||
// Draft promotion now requires repeated occurrence. The legacy
|
|
||||||
// `isStrongReusableSignal` path was the cause of single-utterance Chinese
|
|
||||||
// exhortations being promoted straight to active — P0-2 will reintroduce
|
|
||||||
// outcome-aware signal once the observation layer supplies it.
|
|
||||||
return gap.count >= DRAFT_PROMOTION_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldPromoteToActive(gap: SkillGapRecord): boolean {
|
|
||||||
if (!gap.draft) return false
|
|
||||||
return (
|
|
||||||
gap.count >= ACTIVE_PROMOTION_COUNT ||
|
|
||||||
gap.draftHits >= ACTIVE_PROMOTION_DRAFT_HITS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeSkillGapDraft(
|
|
||||||
gap: SkillGapRecord,
|
|
||||||
project: SkillLearningProjectContext,
|
|
||||||
): Promise<SkillGapMaterialization> {
|
|
||||||
const instinct = createGapInstinct(gap, 'pending')
|
|
||||||
const draftsRoot = join(
|
|
||||||
project.projectRoot ?? project.cwd,
|
|
||||||
'.claude',
|
|
||||||
'skills',
|
|
||||||
'.drafts',
|
|
||||||
)
|
|
||||||
const draft = generateSkillDraft([instinct], {
|
|
||||||
cwd: project.projectRoot ?? project.cwd,
|
|
||||||
outputRoot: draftsRoot,
|
|
||||||
scope: 'project',
|
|
||||||
name: `draft-${buildNameFragment(gap.prompt)}`,
|
|
||||||
description:
|
|
||||||
'Draft learned skill candidate. Promote after repeated evidence or explicit user correction.',
|
|
||||||
})
|
|
||||||
const skillFile = join(draft.outputPath, 'SKILL.md')
|
|
||||||
if (!existsSync(skillFile)) {
|
|
||||||
await writeLearnedSkill({
|
|
||||||
...draft,
|
|
||||||
content:
|
|
||||||
draft.content +
|
|
||||||
'\n## Promotion Rule\n\nDo not move this draft into active skills until the same gap repeats or the user explicitly confirms this should become reusable.\n',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { type: 'draft', name: draft.name, skillPath: skillFile }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeActiveSkillForGap(
|
|
||||||
gap: SkillGapRecord,
|
|
||||||
project: SkillLearningProjectContext,
|
|
||||||
): Promise<SkillGapMaterialization> {
|
|
||||||
const instinct = createGapInstinct(gap, 'active')
|
|
||||||
const draft = generateSkillDraft([instinct], {
|
|
||||||
cwd: project.projectRoot ?? project.cwd,
|
|
||||||
scope: 'project',
|
|
||||||
name: buildNameFragment(gap.prompt),
|
|
||||||
description: buildGapAction(gap.prompt),
|
|
||||||
})
|
|
||||||
const skillFile = join(draft.outputPath, 'SKILL.md')
|
|
||||||
if (!existsSync(skillFile)) {
|
|
||||||
await writeLearnedSkill(draft)
|
|
||||||
}
|
|
||||||
return { type: 'active', name: draft.name, skillPath: skillFile }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGapInstinct(
|
|
||||||
gap: SkillGapRecord,
|
|
||||||
status: StoredInstinct['status'],
|
|
||||||
): StoredInstinct {
|
|
||||||
return createInstinct({
|
|
||||||
trigger: `When the user asks for ${summarize(gap.prompt, 120)}`,
|
|
||||||
action: buildGapAction(gap.prompt),
|
|
||||||
confidence: status === 'active' ? 0.82 : 0.55,
|
|
||||||
domain: inferDomain(gap.prompt),
|
|
||||||
source: 'session-observation',
|
|
||||||
scope: 'project',
|
|
||||||
projectId: gap.projectId,
|
|
||||||
projectName: gap.projectName,
|
|
||||||
evidence: [
|
|
||||||
`Skill gap prompt: ${summarize(gap.prompt, 180)}`,
|
|
||||||
`No high-confidence active skill was auto-loaded.`,
|
|
||||||
`Observed ${gap.count} time(s).`,
|
|
||||||
],
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGapAction(prompt: string): string {
|
|
||||||
if (
|
|
||||||
/feature\s*\(|feature flag|flag_name|stub|no-op|noop|最小实现/i.test(prompt)
|
|
||||||
) {
|
|
||||||
return 'Audit feature flags by scanning feature() call sites, excluding generated/dependency noise, classifying each candidate as stub, shell, MVP, or thin-toggle, and writing an evidence-backed document.'
|
|
||||||
}
|
|
||||||
if (/skill|技能|学习|进化|evolve|learning/i.test(prompt)) {
|
|
||||||
return 'Run skill discovery first; auto-load only high-confidence matching skills; record a skill gap when none match; promote repeated or corrected gaps into learned skills.'
|
|
||||||
}
|
|
||||||
if (/test|测试|stub|调用链|参数/i.test(prompt)) {
|
|
||||||
return 'Infer tests from existing files, parameters, exports, and call chains before simplifying mocks or inventing behavior.'
|
|
||||||
}
|
|
||||||
return `Reuse the workflow learned from this prompt: ${summarize(prompt, 180)}.`
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferDomain(prompt: string): InstinctDomain {
|
|
||||||
const text = prompt.toLowerCase()
|
|
||||||
if (/test|测试|stub|fixture|断言/.test(text)) return 'testing'
|
|
||||||
if (/error|bug|fix|失败|错误|修复|debug/.test(text)) return 'debugging'
|
|
||||||
if (/security|安全|漏洞|secret|token/.test(text)) return 'security'
|
|
||||||
if (/git|commit|branch|pr\b/.test(text)) return 'git'
|
|
||||||
if (/style|lint|format|命名|规范/.test(text)) return 'code-style'
|
|
||||||
return 'workflow'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readSkillGapState(
|
|
||||||
project: SkillLearningProjectContext,
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<SkillGapState> {
|
|
||||||
const path = getSkillGapStatePath(project, rootDir)
|
|
||||||
let raw: string
|
|
||||||
try {
|
|
||||||
raw = await readFile(path, 'utf8')
|
|
||||||
} catch (error) {
|
|
||||||
// Only treat "file doesn't exist yet" as empty state. Every other error
|
|
||||||
// (EACCES, EIO, disk full, etc.) must throw — swallowing them here would
|
|
||||||
// let a subsequent write persist {} and zero out all gap records.
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return { version: 1, gaps: {} }
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return migrateLegacyGapState(JSON.parse(raw) as SkillGapState)
|
|
||||||
} catch {
|
|
||||||
// Corrupt/truncated JSON — don't silently reset. Backup and start fresh,
|
|
||||||
// so the crash isn't masked and the data can be recovered manually.
|
|
||||||
const backup = `${path}.corrupt-${Date.now()}`
|
|
||||||
try {
|
|
||||||
await writeFile(backup, raw, 'utf8')
|
|
||||||
} catch {
|
|
||||||
/* best effort */
|
|
||||||
}
|
|
||||||
return { version: 1, gaps: {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateLegacyGapState(state: SkillGapState): SkillGapState {
|
|
||||||
const migrated: Record<string, SkillGapRecord> = {}
|
|
||||||
for (const [key, record] of Object.entries(state.gaps ?? {})) {
|
|
||||||
const legacy = record as Partial<SkillGapRecord> & {
|
|
||||||
status?: unknown
|
|
||||||
}
|
|
||||||
const draftHits =
|
|
||||||
typeof legacy.draftHits === 'number' && Number.isFinite(legacy.draftHits)
|
|
||||||
? legacy.draftHits
|
|
||||||
: 0
|
|
||||||
const count = typeof legacy.count === 'number' ? legacy.count : 1
|
|
||||||
const normalizedStatus = normalizeLegacyStatus(legacy.status)
|
|
||||||
const hasDraftFile = Boolean(legacy.draft)
|
|
||||||
const hasActiveFile = Boolean(legacy.active)
|
|
||||||
|
|
||||||
let status: SkillGapStatus = normalizedStatus
|
|
||||||
if (status === 'draft' && count < DRAFT_PROMOTION_COUNT && !hasDraftFile) {
|
|
||||||
// Legacy first-call-writes-draft artifact with no file on disk yet.
|
|
||||||
status = 'pending'
|
|
||||||
}
|
|
||||||
if (status === 'active' && !hasActiveFile) {
|
|
||||||
status = hasDraftFile ? 'draft' : 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftHitSessions = Array.isArray(legacy.draftHitSessions)
|
|
||||||
? legacy.draftHitSessions.filter(
|
|
||||||
(session): session is string => typeof session === 'string',
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
migrated[key] = {
|
|
||||||
...(record as SkillGapRecord),
|
|
||||||
count,
|
|
||||||
draftHits,
|
|
||||||
draftHitSessions,
|
|
||||||
status,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { version: 1, gaps: migrated }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLegacyStatus(value: unknown): SkillGapStatus {
|
|
||||||
if (
|
|
||||||
value === 'pending' ||
|
|
||||||
value === 'draft' ||
|
|
||||||
value === 'active' ||
|
|
||||||
value === 'rejected'
|
|
||||||
) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeSkillGapState(
|
|
||||||
project: SkillLearningProjectContext,
|
|
||||||
state: SkillGapState,
|
|
||||||
rootDir?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const path = getSkillGapStatePath(project, rootDir)
|
|
||||||
await mkdir(dirname(path), { recursive: true })
|
|
||||||
// Atomic write: temp + rename. A direct writeFile leaves a truncated file
|
|
||||||
// on crash mid-write; combined with the (now strict) readSkillGapState,
|
|
||||||
// that would lose gap records.
|
|
||||||
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
||||||
await writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
||||||
await rename(tmpPath, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSkillGapStatePath(
|
|
||||||
project: SkillLearningProjectContext,
|
|
||||||
rootDir?: string,
|
|
||||||
): string {
|
|
||||||
const base = rootDir
|
|
||||||
? project.projectId === 'global'
|
|
||||||
? join(rootDir, 'global')
|
|
||||||
: join(rootDir, 'projects', project.projectId)
|
|
||||||
: getProjectStorageDir(project.projectId)
|
|
||||||
return join(base, 'skill-gaps.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSkillGapKey(prompt: string): string {
|
|
||||||
return `${buildNameFragment(prompt)}-${hash(prompt).slice(0, 8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNameFragment(prompt: string): string {
|
|
||||||
const mapped = prompt
|
|
||||||
.replaceAll('技能', ' skill ')
|
|
||||||
.replaceAll('学习', ' learning ')
|
|
||||||
.replaceAll('进化', ' evolution ')
|
|
||||||
.replaceAll('测试', ' testing ')
|
|
||||||
.replaceAll('最小实现', ' minimal implementation ')
|
|
||||||
.toLowerCase()
|
|
||||||
const stop = new Set([
|
|
||||||
'the',
|
|
||||||
'and',
|
|
||||||
'for',
|
|
||||||
'with',
|
|
||||||
'this',
|
|
||||||
'that',
|
|
||||||
'user',
|
|
||||||
'about',
|
|
||||||
'feature',
|
|
||||||
'flag',
|
|
||||||
'name',
|
|
||||||
])
|
|
||||||
const words = (mapped.match(/[a-z0-9][a-z0-9_-]{2,}/g) ?? [])
|
|
||||||
.filter(word => !stop.has(word))
|
|
||||||
.slice(0, 5)
|
|
||||||
const value = words.join('-') || 'learned-gap'
|
|
||||||
return value.slice(0, 54).replace(/-+$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(value: string, max: number): string {
|
|
||||||
return value.replace(/\s+/g, ' ').trim().slice(0, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hash(value: string): string {
|
|
||||||
return createHash('sha1').update(value).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearRuntimeSkillCaches(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { clearCommandsCache } = await import('../../commands.js')
|
|
||||||
clearCommandsCache()
|
|
||||||
} catch {
|
|
||||||
// Best effort only; generated skill files are still available next process.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import type { SkillGapRecord } from './skillGapStore.js'
|
|
||||||
|
|
||||||
export async function generateSkillDraft(
|
|
||||||
_gap: SkillGapRecord,
|
|
||||||
): Promise<{ name: string; skillPath: string } | null> {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeLearnedSkill(_skillPath: string): Promise<void> {
|
|
||||||
// stub
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export type InstinctDomain = string
|
|
||||||
export type SkillGapStatus = 'pending' | 'draft' | 'active' | 'closed'
|
|
||||||
export interface SkillLearningProjectContext {
|
|
||||||
cwd: string
|
|
||||||
projectId: string
|
|
||||||
projectName: string
|
|
||||||
}
|
|
||||||
|
|
@ -15,9 +15,9 @@ import { join } from 'node:path'
|
||||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session memoization to avoid re-emitting the same skill discovery /
|
* Per-session memoization to avoid re-emitting the same skill discovery
|
||||||
* gap signal twice. Each Set is bounded to keep long-running sessions from
|
* signal twice. Each Set is bounded to keep long-running sessions from
|
||||||
* monotonically accumulating skill names and signal keys forever (which
|
* monotonically accumulating skill names forever (which
|
||||||
* was the original session-scoped-but-unbounded design).
|
* was the original session-scoped-but-unbounded design).
|
||||||
*
|
*
|
||||||
* FIFO eviction by insertion order — once the cap is hit, the oldest
|
* FIFO eviction by insertion order — once the cap is hit, the oldest
|
||||||
|
|
@ -28,7 +28,6 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||||
const SESSION_TRACKING_MAX = 1000
|
const SESSION_TRACKING_MAX = 1000
|
||||||
const SESSION_TRACKING_TRIM_TO = 750
|
const SESSION_TRACKING_TRIM_TO = 750
|
||||||
const discoveredThisSession = new Set<string>()
|
const discoveredThisSession = new Set<string>()
|
||||||
const recordedGapSignals = new Set<string>()
|
|
||||||
|
|
||||||
function addBoundedSessionEntry(set: Set<string>, value: string): void {
|
function addBoundedSessionEntry(set: Set<string>, value: string): void {
|
||||||
set.add(value)
|
set.add(value)
|
||||||
|
|
@ -201,49 +200,14 @@ async function markAutoLoadedSkill(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeRecordSkillGap(
|
async function maybeRecordSkillGap(
|
||||||
queryText: string,
|
_queryText: string,
|
||||||
results: SearchResult[],
|
_results: SearchResult[],
|
||||||
context: ToolUseContext,
|
_context: ToolUseContext,
|
||||||
trigger: DiscoverySignal['trigger'],
|
_trigger: DiscoverySignal['trigger'],
|
||||||
): Promise<SkillDiscoveryGap | undefined> {
|
): Promise<SkillDiscoveryGap | undefined> {
|
||||||
if (trigger !== 'user_input') return undefined
|
// skillLearning subsystem has been removed. This function is preserved
|
||||||
if (!queryText.trim()) return undefined
|
// as a stable signature so callers don't need to be null-checked.
|
||||||
|
return undefined
|
||||||
const gapSignalKey = `${trigger}:${queryText.trim().toLowerCase()}`
|
|
||||||
if (recordedGapSignals.has(gapSignalKey)) return undefined
|
|
||||||
addBoundedSessionEntry(recordedGapSignals, 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(
|
export async function startSkillDiscoveryPrefetch(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const acknowledgedBuildWarnings = [
|
||||||
'src/utils/swarm/backends/registry.ts',
|
'src/utils/swarm/backends/registry.ts',
|
||||||
'src/utils/toolSearch.ts',
|
'src/utils/toolSearch.ts',
|
||||||
'src/utils/hooks.ts',
|
'src/utils/hooks.ts',
|
||||||
'src/services/skillLearning/sessionObserver.ts',
|
|
||||||
'src/utils/settings/changeDetector.ts',
|
'src/utils/settings/changeDetector.ts',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user