@@ -2,6 +2,8 @@ package skills
22
33import (
44 "fmt"
5+ "os"
6+ "path/filepath"
57 "strings"
68 "time"
79)
@@ -300,3 +302,164 @@ func FormatCurationReport(r *CurationReport) string {
300302
301303 return b .String ()
302304}
305+
306+ // ── Skill Merging ──────────────────────────────────────────────────────
307+
308+ // MergeSkills consolidates two overlapping skills into one.
309+ // The older skill (by name sort) is kept; the newer is merged into it.
310+ // Bodies are concatenated with a separator; keywords are unioned.
311+ func MergeSkills (keep , remove Skill ) Skill {
312+ merged := keep
313+
314+ // Combine bodies: keep first, then append the other (deduped)
315+ merged .Body = keep .Body + "\n \n ---\n \n ## Merged from " + remove .Name + "\n \n " + remove .Body
316+
317+ // Union topic keywords
318+ topicSet := make (map [string ]bool )
319+ for _ , kw := range keep .Trigger .TopicKeywords {
320+ topicSet [kw ] = true
321+ }
322+ for _ , kw := range remove .Trigger .TopicKeywords {
323+ topicSet [kw ] = true
324+ }
325+ merged .Trigger .TopicKeywords = keysFromSet (topicSet )
326+ if len (merged .Trigger .TopicKeywords ) > 10 {
327+ merged .Trigger .TopicKeywords = merged .Trigger .TopicKeywords [:10 ]
328+ }
329+
330+ // Union action keywords
331+ actionSet := make (map [string ]bool )
332+ for _ , kw := range keep .Trigger .ActionKeywords {
333+ actionSet [kw ] = true
334+ }
335+ for _ , kw := range remove .Trigger .ActionKeywords {
336+ actionSet [kw ] = true
337+ }
338+ merged .Trigger .ActionKeywords = keysFromSet (actionSet )
339+ if len (merged .Trigger .ActionKeywords ) > 5 {
340+ merged .Trigger .ActionKeywords = merged .Trigger .ActionKeywords [:5 ]
341+ }
342+
343+ merged .BodyHash = HashBody (merged .Body )
344+ merged .Description = keep .Description
345+ if merged .Description == "" {
346+ merged .Description = remove .Description
347+ }
348+ merged .Quality = QualityDraft // merged skills are always draft
349+
350+ return merged
351+ }
352+
353+ func keysFromSet (set map [string ]bool ) []string {
354+ keys := make ([]string , 0 , len (set ))
355+ for k := range set {
356+ keys = append (keys , k )
357+ }
358+ return keys
359+ }
360+
361+ // ── Micro-Curation Execution ───────────────────────────────────────────
362+
363+ // ExecuteMicroCuration runs a MicroCurationResult's merges and deletions.
364+ // It writes updated skill files and removes merged/deleted skill directories.
365+ func ExecuteMicroCuration (userDir string , result * MicroCurationResult , allSkills []Skill ) error {
366+ if result == nil || userDir == "" {
367+ return nil
368+ }
369+
370+ // Build a name→skill index
371+ skillIndex := make (map [string ]Skill )
372+ for _ , s := range allSkills {
373+ skillIndex [s .Name ] = s
374+ }
375+
376+ // Execute merges: for each pair in Merged[2n], Merged[2n+1],
377+ // keep the first alphabetically, merge, delete the second
378+ mergedNames := make (map [string ]bool )
379+ for i := 0 ; i + 1 < len (result .Merged ); i += 2 {
380+ keepName := result .Merged [i ]
381+ removeName := result .Merged [i + 1 ]
382+
383+ keep , ok1 := skillIndex [keepName ]
384+ remove , ok2 := skillIndex [removeName ]
385+ if ! ok1 || ! ok2 {
386+ continue
387+ }
388+
389+ // Alphabetically sort: keep the "smaller" name
390+ if keepName > removeName {
391+ keep , remove = remove , keep
392+ keepName , removeName = removeName , keepName
393+ }
394+
395+ merged := MergeSkills (keep , remove )
396+ if err := WriteSkill (userDir , merged ); err != nil {
397+ return fmt .Errorf ("merge: write %s: %w" , keepName , err )
398+ }
399+
400+ // Remove the merged skill directory
401+ removeDir := filepath .Join (userDir , removeName )
402+ os .RemoveAll (removeDir )
403+
404+ mergedNames [keepName ] = true
405+ mergedNames [removeName ] = true
406+ }
407+
408+ // Execute deletions
409+ for _ , name := range result .Deleted {
410+ if mergedNames [name ] {
411+ continue // already removed via merge
412+ }
413+ skillDir := filepath .Join (userDir , name )
414+ os .RemoveAll (skillDir )
415+ }
416+
417+ return nil
418+ }
419+
420+ // ── Auto-Curate Pipeline ───────────────────────────────────────────────
421+
422+ // RunAutoCurate runs the full automatic curation pipeline after a session.
423+ // It runs MicroCuration (with skip-list integration), executes merges/deletions,
424+ // optionally uses LLM for enhancement, and returns a formatted report.
425+ func RunAutoCurate (userDir string , newSkills , allSkills []Skill , cfg SkillsConfig , llmClient LLMClient ) string {
426+ // Build skip list to check skip-threshold deletions
427+ skipList := LoadSkipList (userDir )
428+
429+ // Run micro-curation
430+ result := MicroCuration (userDir , newSkills , allSkills , cfg .Curation )
431+
432+ // Delete skills skipped >= threshold times
433+ for name , entry := range skipList .Skipped {
434+ if entry .TimesSkipped >= cfg .Curation .SkipThreshold && cfg .Curation .SkipThreshold > 0 {
435+ // Only delete if the skill actually exists
436+ for _ , s := range allSkills {
437+ if s .Name == name && s .Quality == QualityDraft {
438+ result .Deleted = append (result .Deleted , name )
439+ result .Notes = append (result .Notes ,
440+ fmt .Sprintf ("deleted %q (skipped %d times)" , name , entry .TimesSkipped ))
441+ break
442+ }
443+ }
444+ }
445+ }
446+
447+ // Execute merges and deletions
448+ if len (result .Merged ) > 0 || len (result .Deleted ) > 0 {
449+ if err := ExecuteMicroCuration (userDir , result , allSkills ); err != nil {
450+ result .Notes = append (result .Notes , fmt .Sprintf ("execution error: %v" , err ))
451+ }
452+ }
453+
454+ // LLM enhancement if configured
455+ if cfg .LLMCurate && llmClient != nil && len (result .Merged ) > 0 {
456+ report := CurateSkills (allSkills , CurateOptions {
457+ StalenessDays : cfg .Curation .StalenessDays ,
458+ })
459+ if llmMsg := EnhanceCurationWithLLM (llmClient , report ); llmMsg != "" {
460+ result .Notes = append (result .Notes , fmt .Sprintf ("LLM: %s" , llmMsg ))
461+ }
462+ }
463+
464+ return FormatMicroCurationResult (result )
465+ }
0 commit comments