@@ -33,6 +33,7 @@ internal sealed class DreamService : IHostedService, IDisposable
3333 private string ? _skillDreamDirective ;
3434 private string ? _skillOptimizeDirective ;
3535 private string ? _prefDreamDirective ;
36+ private string ? _skillGapDirective ;
3637
3738 public DreamService (
3839 ILongTermMemory memory ,
@@ -120,6 +121,19 @@ public Task StartAsync(CancellationToken cancellationToken)
120121 _logger . LogInformation ( "DreamService: loaded pref directive from {Path}" , prefDirectivePath ) ;
121122 }
122123
124+ if ( _skillStore is not null && _conversationLog is not null )
125+ {
126+ var skillGapDirectivePath = ResolvePath ( _options . SkillGapDirectivePath , _profileOptions . BasePath ) ;
127+ _skillGapDirective = File . Exists ( skillGapDirectivePath )
128+ ? File . ReadAllText ( skillGapDirectivePath )
129+ : BuiltInSkillGapDirective ;
130+
131+ if ( ! File . Exists ( skillGapDirectivePath ) )
132+ _logger . LogWarning ( "DreamService: skill gap directive not found at {Path}; using built-in fallback" , skillGapDirectivePath ) ;
133+ else
134+ _logger . LogInformation ( "DreamService: loaded skill gap directive from {Path}" , skillGapDirectivePath ) ;
135+ }
136+
123137 _timer = new Timer (
124138 state => { _ = DreamAsync ( ) ; } ,
125139 null ,
@@ -280,6 +294,9 @@ private async Task DreamAsync()
280294 entry . Id , entry . Category ?? "(none)" , entry . Content ) ;
281295 }
282296
297+ if ( _skillStore is not null )
298+ await RunSkillGapDetectionPassAsync ( ) ;
299+
283300 if ( _skillStore is not null )
284301 await ConsolidateSkillsAsync ( ) ;
285302
@@ -682,6 +699,97 @@ private async Task OptimizeSkillsAsync()
682699 deleted , saved ) ;
683700 }
684701
702+ /// <summary>
703+ /// Scans the conversation log for recurring request patterns that would benefit
704+ /// from a reusable skill and saves any discovered skills directly to the skill store.
705+ /// Runs before skill consolidation so that the consolidation pass can deduplicate
706+ /// any new skills alongside existing ones.
707+ /// </summary>
708+ private async Task RunSkillGapDetectionPassAsync ( )
709+ {
710+ if ( _conversationLog is null || _skillStore is null || ! _options . SkillGapEnabled )
711+ return ;
712+
713+ var entries = await _conversationLog . ReadAllAsync ( ) ;
714+ if ( entries . Count == 0 )
715+ {
716+ _logger . LogDebug ( "DreamService: skill gap detection — no log entries; skipping" ) ;
717+ return ;
718+ }
719+
720+ var existingSkills = await _skillStore . ListAsync ( ) ;
721+
722+ _logger . LogInformation (
723+ "DreamService: skill gap detection pass — {EntryCount} log entries, {SkillCount} existing skills" ,
724+ entries . Count , existingSkills . Count ) ;
725+
726+ // Build user message: turns grouped by session + existing skill catalog
727+ var userMessage = new StringBuilder ( ) ;
728+ userMessage . AppendLine ( "Review the following conversation log for recurring request patterns:" ) ;
729+ userMessage . AppendLine ( ) ;
730+
731+ var bySession = entries
732+ . GroupBy ( e => e . SessionId )
733+ . ToDictionary ( g => g . Key , g => g . ToList ( ) ) ;
734+
735+ foreach ( var ( sessionId , sessionEntries ) in bySession )
736+ {
737+ userMessage . AppendLine ( $ "## Session: { sessionId } ") ;
738+ foreach ( var e in sessionEntries )
739+ userMessage . AppendLine ( $ "[{ e . Role } ] { e . Content } ") ;
740+ userMessage . AppendLine ( ) ;
741+ }
742+
743+ if ( existingSkills . Count > 0 )
744+ {
745+ userMessage . AppendLine ( "## Existing skills (do not duplicate these):" ) ;
746+ foreach ( var s in existingSkills )
747+ userMessage . AppendLine ( $ "- { s . Name } : { s . Summary } ") ;
748+ userMessage . AppendLine ( ) ;
749+ }
750+
751+ var messages = new List < ChatMessage >
752+ {
753+ new ( ChatRole . System , _skillGapDirective ?? BuiltInSkillGapDirective ) ,
754+ new ( ChatRole . User , userMessage . ToString ( ) )
755+ } ;
756+
757+ var response = await _llmClient . GetResponseAsync ( messages , new ChatOptions ( ) ) ;
758+ var raw = response . Text ? . Trim ( ) ?? string . Empty ;
759+ var json = ExtractJsonObject ( raw ) ;
760+
761+ if ( string . IsNullOrEmpty ( json ) )
762+ {
763+ _logger . LogWarning ( "DreamService: skill gap LLM returned no parseable JSON; skipping" ) ;
764+ return ;
765+ }
766+
767+ _logger . LogDebug ( "DreamService: skill gap JSON ({Length} chars): {Json}" , json . Length , json ) ;
768+
769+ var result = JsonSerializer . Deserialize < SkillGapResultDto > ( json , JsonOptions ) ;
770+ var saved = 0 ;
771+
772+ foreach ( var dto in result ? . ToSave ?? [ ] )
773+ {
774+ if ( string . IsNullOrWhiteSpace ( dto . Name ) || string . IsNullOrWhiteSpace ( dto . Content ) )
775+ continue ;
776+
777+ var skill = new Skill (
778+ Name : dto . Name . Trim ( ) ,
779+ Summary : dto . Summary ? . Trim ( ) ?? string . Empty ,
780+ Content : dto . Content . Trim ( ) ,
781+ CreatedAt : DateTimeOffset . UtcNow ,
782+ UpdatedAt : DateTimeOffset . UtcNow ,
783+ LastUsedAt : null ) ;
784+
785+ await _skillStore . SaveAsync ( skill ) ;
786+ saved ++ ;
787+ _logger . LogDebug ( "DreamService: skill gap created new skill '{Name}'" , skill . Name ) ;
788+ }
789+
790+ _logger . LogInformation ( "DreamService: skill gap detection complete — {Saved} new skill(s) created" , saved ) ;
791+ }
792+
685793 /// <summary>
686794 /// Analyzes the accumulated conversation log for durable user preference patterns
687795 /// and saves inferred preferences as tagged memory entries.
@@ -866,6 +974,27 @@ Only improve skills where the failure is clearly addressable by better instructi
866974 If no improvements are warranted, return: { "toDelete": [], "toSave": [] }
867975 """ ;
868976
977+ private const string BuiltInSkillGapDirective = """
978+ You are a skill gap detection assistant. Review the conversation log for recurring request
979+ patterns that would benefit from a reusable skill.
980+
981+ Only suggest a new skill when the same type of request appears 2 or more times across
982+ different sessions, or with clear recurring intent in a single session.
983+
984+ Existing skills are listed below — do not suggest skills already adequately covered by them.
985+
986+ Return ONLY a JSON object:
987+ { "toSave": [ { "name": "...", "summary": "...", "content": "..." } ] }
988+
989+ Rules:
990+ - name: short, lowercase, hyphen-separated (e.g. "summarize-emails", "daily-standup")
991+ - summary: one sentence, 15 words or fewer
992+ - content: step-by-step instructions the agent should follow when executing this skill
993+ - Only suggest skills with clear, repeatable value across sessions
994+
995+ If no recurring patterns warrant a new skill, return: { "toSave": [] }
996+ """ ;
997+
869998 private const string BuiltInPrefDirective = """
870999 You are a user preference inference assistant. Review the conversation log for durable, recurring preference patterns.
8711000 Look for: formatting preferences, comment style, tool corrections, topic clusters, and communication style signals.
@@ -913,4 +1042,8 @@ private sealed record PrefEntryDto(
9131042 string ? Category ,
9141043 IReadOnlyList < string > ? Tags ,
9151044 IReadOnlyDictionary < string , string > ? Metadata ) ;
1045+
1046+ private sealed record SkillGapResultDto ( List < SkillGapEntryDto > ? ToSave ) ;
1047+
1048+ private sealed record SkillGapEntryDto ( string Name , string ? Summary , string Content ) ;
9161049}
0 commit comments