Skip to content

Commit af4933f

Browse files
committed
feat: auto-curation with merge execution, skip-based deletion, LLM consolidation
- MergeSkills() combines overlapping skills (union keywords, concatenated bodies) - ExecuteMicroCuration() performs actual merges and deletions on disk - RunAutoCurate() is the full post-session pipeline: MicroCuration + skip-threshold deletion + merge execution + optional LLM enhancement - MicroCuration enhanced: overlap groups auto-merge draft skills, staleness detection with auto-prune support, skip-based deletion - Wired into CLI (runAutoCurate with LLM client) and Telegram (nil LLM) - 8 new test functions: MergeSkills, ExecuteMicroCuration merge/delete, MicroCuration overlap/staleness, RunAutoCurate skip deletion, keysFromSet
1 parent 5934f31 commit af4933f

4 files changed

Lines changed: 410 additions & 7 deletions

File tree

cmd/odek/main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,7 +1359,7 @@ func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager,
13591359
if len(result.Saved) > 0 {
13601360
sm.Reload()
13611361
// Run micro-curation after auto-save
1362-
runMicroCuration(userDir, sm, skillsCfg)
1362+
runAutoCurate(userDir, sm, skillsCfg, llmClient)
13631363
}
13641364
return
13651365
}
@@ -1394,17 +1394,17 @@ func runLearnLoop(messages []llm.Message, task string, sm *skills.SkillManager,
13941394
}
13951395
}
13961396

1397-
// runMicroCuration triggers micro-curation after auto-save.
1398-
func runMicroCuration(userDir string, sm *skills.SkillManager, cfg skills.SkillsConfig) {
1397+
// runAutoCurate triggers automatic curation after auto-save.
1398+
func runAutoCurate(userDir string, sm *skills.SkillManager, cfg skills.SkillsConfig, llmClient skills.LLMClient) {
13991399
allSkills := sm.AllSkills()
14001400
var newSkills []skills.Skill
14011401
for _, s := range allSkills {
14021402
if s.Quality == skills.QualityDraft {
14031403
newSkills = append(newSkills, s)
14041404
}
14051405
}
1406-
result := skills.MicroCuration(userDir, newSkills, allSkills, cfg.Curation)
1407-
if msg := skills.FormatMicroCurationResult(result); msg != "" {
1406+
msg := skills.RunAutoCurate(userDir, newSkills, allSkills, cfg, llmClient)
1407+
if msg != "" {
14081408
fmt.Fprint(os.Stderr, msg)
14091409
}
14101410
}

cmd/odek/telegram.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -885,8 +885,8 @@ func handleChatMessage(
885885
newSkills = append(newSkills, s)
886886
}
887887
}
888-
mcResult := skills.MicroCuration(userDir, newSkills, allSkills, skillsCfg.Curation)
889-
if msg := skills.FormatMicroCurationResult(mcResult); msg != "" {
888+
msg := skills.RunAutoCurate(userDir, newSkills, allSkills, *skillsCfg, nil)
889+
if msg != "" {
890890
go bot.SendMessage(chatID, msg, nil)
891891
}
892892
}

internal/skills/curator.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package skills
22

33
import (
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

Comments
 (0)