Skip to content

Commit fad99dd

Browse files
committed
feat: tool compression, episode semantic search, conversation skill extraction (v0.27.0)
A — Auto-skill generation from conversations: - ExtractSkillsFromConversation: new LLM-based heuristic that analyzes the full conversation history (user intent, agent reasoning, tool calls, final outcome) to discover reusable skills. Unlike pattern-based heuristics (multi-step, error-recovery), this catches architectural decisions, debugging strategies, and workflow patterns. - Called from learnAndSuggest alongside existing heuristics when LLM learning is enabled. B — Episode semantic search: - NewRPRanker: uses go-vector RandomProjections for cosine similarity ranking of episodes. Replaces recency-based default ranking with actual semantic matching — 'bug we fixed last week' finds the right episode. No LLM cost, <1ms per search. - Now the default ranker when LLM search is not explicitly enabled. C — Tool output compression: - Large tool outputs (>4KB) are automatically truncated to head+tail with an omission notice. Keeps 3KB head + 1KB tail, preserving the most important information while saving context window for reasoning.
1 parent 3815b3b commit fad99dd

5 files changed

Lines changed: 169 additions & 2 deletions

File tree

cmd/odek/main.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,22 @@ func learnAndSuggest(messages []llm.Message, sm *skills.SkillManager, llmClient
13121312
userMessages := extractUserMessages(messages)
13131313
suggestions := skills.RunAllHeuristics(skillMsgs, userMessages)
13141314

1315+
// Conversation-level skill extraction — uses full context, not just tool patterns.
1316+
// Catches architectural decisions, debugging strategies, and workflow patterns
1317+
// that the pattern-based heuristics miss.
1318+
if llmLearn && llmClient != nil {
1319+
if convSkill := skills.ExtractSkillsFromConversation(llmClient, skillMsgs, userMessages); convSkill != nil {
1320+
// Build a command log from tool calls for context
1321+
calls := skills.ExtractToolCalls(skillMsgs)
1322+
cmds := make([]string, 0, len(calls))
1323+
for _, c := range calls {
1324+
cmds = append(cmds, c.Input)
1325+
}
1326+
convSkill.CommandLog = cmds
1327+
suggestions = append(suggestions, *convSkill)
1328+
}
1329+
}
1330+
13151331
// Apply LLM enhancement to each suggestion
13161332
for i := range suggestions {
13171333
if llmLearn && llmClient != nil {

internal/loop/loop.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,20 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
523523
e.toolEventHandler("tool_result", tc.Function.Name, output)
524524
}
525525

526-
// Wrap tool output in unbreakable delimiters so the model
526+
// Compress large tool outputs to save context window.
527+
// Keep the first and last portions — head usually contains
528+
// the most important info, tail may have final results.
529+
const maxOutput = 4096
530+
if len(output) > maxOutput {
531+
head := maxOutput * 3 / 4 // 3KB head
532+
tail := maxOutput / 4 // 1KB tail
533+
output = output[:head] +
534+
fmt.Sprintf("\n\n... [%d bytes omitted — output was %d bytes total] ...\n\n",
535+
len(output)-head-tail, len(output)) +
536+
output[len(output)-tail:]
537+
}
538+
539+
// Wrap tool output in unbreakable delimiters so the model
527540
// treats it as DATA, never as instructions. The header and
528541
// footer both explicitly frame the content as untrusted data.
529542
// Even if the output contains "ignore previous instructions",

internal/memory/episodes.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"math"
78
"os"
89
"path/filepath"
910
"sort"
1011
"strings"
1112
"time"
13+
14+
"github.com/BackendStack21/go-vector/pkg/vector"
1215
)
1316

1417
// maxEpisodeSummaryBytes caps how much summary text we store per episode.
@@ -248,5 +251,78 @@ func NewLLMRanker(llm LLMClient) RankStrategy {
248251
return defaultRanker(query, episodes)
249252
}
250253
return ranked, nil
254+
}
255+
}
256+
257+
// NewRPRanker creates a RankStrategy that uses RandomProjections (go-vector)
258+
// for semantic similarity search over episodes. No LLM calls — pure vector
259+
// math. Falls back to recency ordering if RP fitting fails.
260+
//
261+
// The ranker fits the RP embedder on episode summaries on each call.
262+
// With the typical number of episodes (< 100, 120-char summaries each),
263+
// fitting takes < 1ms and is negligible compared to LLM latency.
264+
func NewRPRanker(dims int) RankStrategy {
265+
if dims <= 0 {
266+
dims = 64 // lower dims = faster, fine for short summaries
267+
}
268+
return func(query string, episodes []EpisodeMeta) ([]EpisodeMeta, error) {
269+
if len(episodes) <= 1 {
270+
out := make([]EpisodeMeta, len(episodes))
271+
copy(out, episodes)
272+
return out, nil
273+
}
274+
275+
// Build corpus from episode summaries
276+
corpus := make([]string, len(episodes))
277+
for i, ep := range episodes {
278+
corpus[i] = ep.Summary
279+
}
280+
281+
// Fit RP and embed query + corpus
282+
rp := vector.NewRandomProjections(dims)
283+
rp.Fit(append(corpus, query))
284+
queryVec, _ := rp.Embed(query)
285+
286+
// Score each episode by cosine similarity
287+
type scored struct {
288+
idx int
289+
score float32
290+
}
291+
scores := make([]scored, len(episodes))
292+
for i, summary := range corpus {
293+
vec, _ := rp.Embed(summary)
294+
sim := cosineVector(queryVec, vec)
295+
scores[i] = scored{idx: i, score: sim}
296+
}
297+
298+
// Sort by score descending
299+
sort.Slice(scores, func(i, j int) bool {
300+
return scores[i].score > scores[j].score
301+
})
302+
303+
out := make([]EpisodeMeta, len(episodes))
304+
for i, s := range scores {
305+
out[i] = episodes[s.idx]
306+
}
307+
return out, nil
308+
}
251309
}
310+
311+
// cosineVector computes cosine similarity between two go-vector Vectors.
312+
func cosineVector(a, b vector.Vector) float32 {
313+
if len(a) != len(b) || len(a) == 0 {
314+
return 0
315+
}
316+
var dot, normA, normB float64
317+
for i := range a {
318+
da := float64(a[i])
319+
db := float64(b[i])
320+
dot += da * db
321+
normA += da * da
322+
normB += db * db
323+
}
324+
if normA == 0 || normB == 0 {
325+
return 0
326+
}
327+
return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB)))
252328
}

internal/memory/memory.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,13 @@ func NewMemoryManager(memoryDir string, llc LLMClient, cfg MemoryConfig) *Memory
135135
episodesDir := memoryDir
136136

137137
factStore := NewFactStore(factsDir, cfg.FactsLimitUser, cfg.FactsLimitEnv)
138-
// Use LLM-based episode ranker when an LLM client is available and enabled
138+
// Use LLM-based episode ranker when an LLM client is available and enabled.
139+
// Otherwise use RP (RandomProjections) semantic similarity — fast, no LLM cost.
139140
var rankFn RankStrategy
140141
if llc != nil && cfg.LLMSearch != nil && *cfg.LLMSearch {
141142
rankFn = NewLLMRanker(llc)
143+
} else {
144+
rankFn = NewRPRanker(64)
142145
}
143146
episodeStore := NewEpisodeStore(episodesDir, rankFn)
144147
mergeDetector := NewMergeDetectorWithThresholds(0, cfg.MergeThreshold, cfg.AddThreshold)

internal/skills/llm_enhance.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,62 @@ func EnhanceCurationWithLLM(llm LLMClient, report *CurationReport) string {
163163
}
164164
return resp
165165
}
166+
167+
// ExtractSkillsFromConversation takes the full conversation history (all messages)
168+
// and asks the LLM to identify whether a reusable skill was demonstrated.
169+
// Unlike GenerateSkillWithLLM (which only enhances pattern-detected tool call
170+
// sequences), this analyzes the complete interaction — user intent, agent
171+
// reasoning, tool calls, and final outcome — to discover deeper patterns.
172+
//
173+
// Returns nil if the LLM call fails or no skill is found.
174+
func ExtractSkillsFromConversation(llm LLMClient, messages []LlmMessage, userMessages []string) *SkillSuggestion {
175+
if llm == nil || len(messages) < 3 {
176+
return nil
177+
}
178+
179+
var b strings.Builder
180+
b.WriteString("Analyze this AI coding agent conversation for reusable skills:\n\n")
181+
182+
// Include up to 20 messages (user/assistant/tool) to give full context
183+
maxMsgs := len(messages)
184+
if maxMsgs > 20 {
185+
maxMsgs = 20
186+
}
187+
start := len(messages) - maxMsgs
188+
for i := start; i < len(messages); i++ {
189+
m := messages[i]
190+
content := m.Content
191+
if len(content) > 300 {
192+
content = content[:297] + "..."
193+
}
194+
label := m.Role
195+
if m.Name != "" {
196+
label = m.Role + ":" + m.Name
197+
}
198+
b.WriteString(fmt.Sprintf("[%s] %s\n", label, content))
199+
}
200+
201+
b.WriteString("\nDid the agent demonstrate a reusable skill or procedure? ")
202+
b.WriteString("If YES, generate a skill file. If NO, respond with just 'none'.\n\n")
203+
b.WriteString("Output format:\n")
204+
b.WriteString("NAME: <short kebab-case name>\n")
205+
b.WriteString("DESCRIPTION: <one-line, max 100 chars>\n")
206+
b.WriteString("TOPICS: <3-5 comma-separated topic keywords>\n")
207+
b.WriteString("ACTIONS: <2-3 comma-separated action keywords>\n")
208+
b.WriteString("BODY:\n")
209+
b.WriteString("<markdown body with ## Overview, ## Step-by-Step, ## Common Pitfalls, ## Verification>\n")
210+
211+
resp, err := llm.SimpleCall(context.Background(),
212+
"You are a skill curator. Given a conversation trace, identify if a reusable skill or procedure was demonstrated. If yes, generate a structured SKILL.md. If no clear reusable pattern exists, respond with just 'none'. Be conservative — only extract genuinely reusable skills.",
213+
b.String(),
214+
)
215+
if err != nil || resp == "" || strings.TrimSpace(resp) == "none" {
216+
return nil
217+
}
218+
219+
s := parseLLMSuggestion(resp)
220+
if s != nil {
221+
s.Heuristic = "conversation-extracted"
222+
}
223+
return s
224+
}

0 commit comments

Comments
 (0)