Skip to content

Commit 7a8bfa6

Browse files
authored
Merge pull request #361 from AppSprout-dev/feat/agent-recall-quality
feat: improve recall quality for LLM agents, fix Windows self-update
2 parents ea0536e + aa07982 commit 7a8bfa6

File tree

10 files changed

+261
-23
lines changed

10 files changed

+261
-23
lines changed

cmd/mnemonic/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,11 @@ func serveCommand(configPath string) {
12261226
}
12271227
slog.SetDefault(log)
12281228

1229+
// Clean up leftover .old binary from a previous Windows update
1230+
if err := updater.CleanupOldBinary(); err != nil {
1231+
log.Warn("failed to clean up old binary after update", "error", err)
1232+
}
1233+
12291234
// Create data directory if it doesn't exist
12301235
if err := cfg.EnsureDataDir(); err != nil {
12311236
die(exitPermission, fmt.Sprintf("creating data directory: %v", err), "check permissions on ~/.mnemonic/")
@@ -1874,12 +1879,26 @@ func buildRetrievalConfig(cfg *config.Config) retrieval.RetrievalConfig {
18741879

18751880
FeedbackWeight: float32(cfg.Retrieval.FeedbackWeight),
18761881
SourceWeights: convertSourceWeights(cfg.Retrieval.SourceWeights),
1882+
TypeWeights: convertSourceWeights(cfg.Retrieval.TypeWeights),
18771883

18781884
ContextBoostWindowMin: cfg.Perception.RecallBoostWindowMin,
18791885
ContextBoostMax: float32(cfg.Perception.RecallBoostMax),
1886+
ContextBoostSources: convertContextBoostSources(cfg.Retrieval.ContextBoostSources),
18801887
}
18811888
}
18821889

1890+
// convertContextBoostSources converts []string to map[string]bool.
1891+
func convertContextBoostSources(src []string) map[string]bool {
1892+
if src == nil {
1893+
return nil
1894+
}
1895+
out := make(map[string]bool, len(src))
1896+
for _, s := range src {
1897+
out[s] = true
1898+
}
1899+
return out
1900+
}
1901+
18831902
// convertSourceWeights converts map[string]float64 to map[string]float32.
18841903
func convertSourceWeights(src map[string]float64) map[string]float32 {
18851904
if src == nil {

internal/agent/consolidation/agent.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type ConsolidationConfig struct {
2828
MaxMemoriesPerCycle int
2929
MaxMergesPerCycle int
3030
MinClusterSize int
31+
MinEvidenceSalience float32 // minimum salience for memories to count as pattern evidence (default: 0.5)
3132
AssocPruneThreshold float32 // prune associations below this strength
3233

3334
// Salience decay tunables
@@ -76,6 +77,7 @@ func DefaultConfig() ConsolidationConfig {
7677
MaxMemoriesPerCycle: 100,
7778
MaxMergesPerCycle: 5,
7879
MinClusterSize: 3,
80+
MinEvidenceSalience: 0.5,
7981
AssocPruneThreshold: 0.05,
8082
RecencyProtection24h: 0.8,
8183
RecencyProtection168h: 0.9,
@@ -874,21 +876,30 @@ func (ca *ConsolidationAgent) extractPatterns(ctx context.Context) (int, error)
874876
// processPatternClusters handles the common logic for evaluating a set of memory clusters
875877
// as potential patterns: strengthening existing matches or identifying new ones via LLM.
876878
func (ca *ConsolidationAgent) processPatternClusters(ctx context.Context, clusters [][]store.Memory, project string, budget int) int {
879+
minSalience := cfgFloat32(ca.config.MinEvidenceSalience, 0.5)
877880
extracted := 0
878881
for _, cluster := range clusters {
879882
if extracted >= budget {
880883
break
881884
}
882-
if len(cluster) < 3 {
885+
886+
// Filter cluster to salience-qualified memories
887+
var qualified []store.Memory
888+
for _, mem := range cluster {
889+
if mem.Salience >= minSalience {
890+
qualified = append(qualified, mem)
891+
}
892+
}
893+
if len(qualified) < 3 {
883894
continue
884895
}
885896

886897
// Check if this cluster matches an existing pattern (by embedding similarity)
887-
existing, err := ca.findMatchingPattern(ctx, cluster)
898+
existing, err := ca.findMatchingPattern(ctx, qualified)
888899
if err == nil && existing != nil {
889900
// Count genuinely new evidence
890901
newEvidence := 0
891-
for _, mem := range cluster {
902+
for _, mem := range qualified {
892903
if !containsString(existing.EvidenceIDs, mem.ID) {
893904
existing.EvidenceIDs = append(existing.EvidenceIDs, mem.ID)
894905
newEvidence++
@@ -922,13 +933,13 @@ func (ca *ConsolidationAgent) processPatternClusters(ctx context.Context, cluste
922933
}
923934

924935
// Ask LLM if there's a recurring pattern
925-
pattern, err := ca.identifyPattern(ctx, cluster, project)
936+
pattern, err := ca.identifyPattern(ctx, qualified, project)
926937
if err != nil {
927-
ca.log.Warn("pattern identification failed", "project", project, "cluster_size", len(cluster), "error", err)
938+
ca.log.Warn("pattern identification failed", "project", project, "cluster_size", len(qualified), "error", err)
928939
continue
929940
}
930941
if pattern == nil {
931-
ca.log.Info("pattern extraction: LLM rejected cluster (not a pattern)", "project", project, "cluster_size", len(cluster))
942+
ca.log.Info("pattern extraction: LLM rejected cluster (not a pattern)", "project", project, "cluster_size", len(qualified))
932943
continue
933944
}
934945

@@ -947,7 +958,7 @@ func (ca *ConsolidationAgent) processPatternClusters(ctx context.Context, cluste
947958
embSim := agentutil.CosineSimilarity(pattern.Embedding, ep.Embedding)
948959
titleSim := normalizedTitleSimilarity(pattern.Title, ep.Title)
949960
if isDuplicate(pattern.Title, ep.Title, pattern.Embedding, ep.Embedding, 0.5, 0.75) {
950-
for _, mem := range cluster {
961+
for _, mem := range qualified {
951962
if !containsString(ep.EvidenceIDs, mem.ID) {
952963
ep.EvidenceIDs = append(ep.EvidenceIDs, mem.ID)
953964
}

internal/agent/retrieval/agent.go

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,15 @@ type RetrievalConfig struct {
6262
FeedbackWeight float32 // weight of user feedback score in ranking (default: 0.15)
6363

6464
// Source-weighted scoring
65-
SourceWeights map[string]float32 // per-source multipliers (default: mcp=1.0, terminal=0.8, clipboard=0.6, filesystem=0.5)
65+
SourceWeights map[string]float32 // per-source multipliers (default: mcp=1.5, terminal=0.8, clipboard=0.6, filesystem=0.5)
66+
67+
// Memory type scoring — actionable types (decision, error) rank higher than observations
68+
TypeWeights map[string]float32 // per-type multipliers (default: decision=1.3, error=1.25, insight=1.2, learning=1.15)
6669

6770
// Context boost from watcher activity
68-
ContextBoostWindowMin int // minutes context boost decays over (default: 30)
69-
ContextBoostMax float32 // max additive boost from watcher context (default: 0.2)
71+
ContextBoostWindowMin int // minutes context boost decays over (default: 30)
72+
ContextBoostMax float32 // max additive boost from watcher context (default: 0.2)
73+
ContextBoostSources map[string]bool // sources eligible for context boost (nil = all sources)
7074
}
7175

7276
// DefaultConfig returns sensible defaults for retrieval configuration.
@@ -106,13 +110,23 @@ func DefaultConfig() RetrievalConfig {
106110

107111
FeedbackWeight: 0.15,
108112
SourceWeights: map[string]float32{
109-
"mcp": 1.0,
113+
"mcp": 1.5,
110114
"terminal": 0.8,
111115
"clipboard": 0.6,
112116
"filesystem": 0.5,
113117
},
118+
TypeWeights: map[string]float32{
119+
"decision": 1.3,
120+
"error": 1.25,
121+
"insight": 1.2,
122+
"learning": 1.15,
123+
},
114124
ContextBoostWindowMin: 30,
115125
ContextBoostMax: 0.2,
126+
ContextBoostSources: map[string]bool{
127+
"mcp": true,
128+
"terminal": true,
129+
},
116130
}
117131
}
118132

@@ -391,12 +405,12 @@ func (ra *RetrievalAgent) Query(ctx context.Context, req QueryRequest) (QueryRes
391405
evidenceBoost := make(map[string]float32)
392406
for _, p := range matchedPatterns {
393407
for _, eid := range p.EvidenceIDs {
394-
evidenceBoost[eid] += 0.1
408+
evidenceBoost[eid] += 0.1 * p.Strength
395409
}
396410
}
397411
for _, a := range matchedAbstractions {
398412
for _, mid := range a.SourceMemoryIDs {
399-
evidenceBoost[mid] += 0.05
413+
evidenceBoost[mid] += 0.05 * a.Confidence
400414
}
401415
}
402416
for i, r := range ranked {
@@ -623,6 +637,7 @@ func (ra *RetrievalAgent) rankResults(ctx context.Context, activated map[string]
623637
recencyBonus float32
624638
activityBonus float32
625639
contextBoost float32
640+
typeWeight float32
626641
sourceWeight float32
627642
feedbackAdjust float32
628643
}
@@ -666,10 +681,13 @@ func (ra *RetrievalAgent) rankResults(ctx context.Context, activated map[string]
666681
actScale := float64(f32Or(ra.config.ActivityBonusScale, 0.02))
667682
activityBonus := float32(math.Min(actMax, actScale*math.Log1p(float64(state.activationCount))))
668683

669-
// Context boost from recent watcher activity
684+
// Context boost from recent watcher activity (only for eligible sources)
670685
var contextBoost float32
671686
if ra.activity != nil {
672-
contextBoost = ra.activity.boostForMemory(mem.Concepts)
687+
eligible := ra.config.ContextBoostSources == nil || ra.config.ContextBoostSources[mem.Source]
688+
if eligible {
689+
contextBoost = ra.activity.boostForMemory(mem.Concepts)
690+
}
673691
}
674692

675693
// Combined score
@@ -686,6 +704,15 @@ func (ra *RetrievalAgent) rankResults(ctx context.Context, activated map[string]
686704
}
687705
}
688706

707+
// Memory type weight — actionable types (decision, error) rank higher than observations
708+
typeWeight := float32(1.0)
709+
if ra.config.TypeWeights != nil {
710+
if tw, ok := ra.config.TypeWeights[mem.Type]; ok && tw > 0 {
711+
typeWeight = tw
712+
}
713+
}
714+
baseScore *= typeWeight
715+
689716
// Apply source weight as a multiplier (before feedback adjustment)
690717
sourceWeight := float32(1.0)
691718
if ra.config.SourceWeights != nil {
@@ -709,6 +736,7 @@ func (ra *RetrievalAgent) rankResults(ctx context.Context, activated map[string]
709736
recencyBonus: recencyBonus,
710737
activityBonus: activityBonus,
711738
contextBoost: contextBoost,
739+
typeWeight: typeWeight,
712740
sourceWeight: sourceWeight,
713741
feedbackAdjust: feedbackAdjust,
714742
})
@@ -725,8 +753,8 @@ func (ra *RetrievalAgent) rankResults(ctx context.Context, activated map[string]
725753
explanation := ""
726754
if includeReasoning {
727755
explanation = fmt.Sprintf(
728-
"activation: %.3f, recency_bonus: %.3f, activity_bonus: %.3f, context_boost: %.3f, source_weight: %.2f, feedback_adjust: %.3f, combined_score: %.3f",
729-
sm.activation, sm.recencyBonus, sm.activityBonus, sm.contextBoost, sm.sourceWeight, sm.feedbackAdjust, sm.finalScore,
756+
"activation: %.3f, recency_bonus: %.3f, activity_bonus: %.3f, context_boost: %.3f, type_weight: %.2f, source_weight: %.2f, feedback_adjust: %.3f, combined_score: %.3f",
757+
sm.activation, sm.recencyBonus, sm.activityBonus, sm.contextBoost, sm.typeWeight, sm.sourceWeight, sm.feedbackAdjust, sm.finalScore,
730758
)
731759
}
732760

internal/config/config.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,13 @@ type RetrievalConfig struct {
287287
FeedbackWeight float64 `yaml:"feedback_weight"` // weight of user feedback score in ranking (default 0.15)
288288

289289
// Source-weighted scoring
290-
SourceWeights map[string]float64 `yaml:"source_weights"` // per-source multipliers (default: mcp=1.0, terminal=0.8, clipboard=0.6, filesystem=0.5)
290+
SourceWeights map[string]float64 `yaml:"source_weights"` // per-source multipliers (default: mcp=1.5, terminal=0.8, clipboard=0.6, filesystem=0.5)
291+
292+
// Memory type scoring
293+
TypeWeights map[string]float64 `yaml:"type_weights"` // per-type multipliers (default: decision=1.3, error=1.25, insight=1.2, learning=1.15)
294+
295+
// Context boost source eligibility
296+
ContextBoostSources []string `yaml:"context_boost_sources"` // sources eligible for context boost (default: [mcp, terminal])
291297
}
292298

293299
// MetacognitionConfig holds metacognition settings.
@@ -700,11 +706,18 @@ func Default() *Config {
700706

701707
FeedbackWeight: 0.15,
702708
SourceWeights: map[string]float64{
703-
"mcp": 1.0,
709+
"mcp": 1.5,
704710
"terminal": 0.8,
705711
"clipboard": 0.6,
706712
"filesystem": 0.5,
707713
},
714+
TypeWeights: map[string]float64{
715+
"decision": 1.3,
716+
"error": 1.25,
717+
"insight": 1.2,
718+
"learning": 1.15,
719+
},
720+
ContextBoostSources: []string{"mcp", "terminal"},
708721
},
709722
Metacognition: MetacognitionConfig{
710723
Enabled: true,
@@ -743,7 +756,7 @@ func Default() *Config {
743756
Enabled: true,
744757
IntervalRaw: "6h",
745758
Interval: 6 * time.Hour,
746-
MinStrength: 0.4,
759+
MinStrength: 0.7,
747760
MaxLLMCalls: 5,
748761
StartupDelaySec: 300,
749762
DefaultConfidence: 0.6,

internal/mcp/server.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,6 +1583,16 @@ func (srv *MCPServer) handleRecallProject(ctx context.Context, args map[string]i
15831583
}
15841584
}
15851585

1586+
// Filter patterns to quality threshold
1587+
if len(patterns) > 0 {
1588+
filtered := patterns[:0]
1589+
for _, p := range patterns {
1590+
if p.Strength >= 0.3 {
1591+
filtered = append(filtered, p)
1592+
}
1593+
}
1594+
patterns = filtered
1595+
}
15861596
if len(patterns) > 0 {
15871597
text += fmt.Sprintf("\nPatterns (%d):\n", len(patterns))
15881598
for _, p := range patterns {
@@ -1810,12 +1820,28 @@ func (srv *MCPServer) handleGetPatterns(ctx context.Context, args map[string]int
18101820
limit = int(l)
18111821
}
18121822

1823+
minStrength := float32(0.3)
1824+
if ms, ok := args["min_strength"].(float64); ok {
1825+
minStrength = float32(ms)
1826+
}
1827+
18131828
patterns, err := srv.store.ListPatterns(ctx, project, limit)
18141829
if err != nil {
18151830
srv.log.Error("failed to list patterns", "error", err)
18161831
return nil, fmt.Errorf("failed to list patterns: %w", err)
18171832
}
18181833

1834+
// Filter by minimum strength
1835+
if minStrength > 0 {
1836+
filtered := patterns[:0]
1837+
for _, p := range patterns {
1838+
if p.Strength >= minStrength {
1839+
filtered = append(filtered, p)
1840+
}
1841+
}
1842+
patterns = filtered
1843+
}
1844+
18191845
if len(patterns) == 0 {
18201846
return toolResult("No patterns discovered yet. Patterns emerge as the system processes more memories and runs consolidation cycles."), nil
18211847
}

internal/mcp/tools.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,10 @@ func getPatternsToolDef() ToolDefinition {
359359
"type": "integer",
360360
"description": "Maximum number of patterns to return (default: 10)",
361361
},
362+
"min_strength": map[string]interface{}{
363+
"type": "number",
364+
"description": "Minimum pattern strength to return (default: 0.3). Set to 0 for all patterns.",
365+
},
362366
},
363367
"required": []string{},
364368
},

internal/updater/replace_unix.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !windows
2+
3+
package updater
4+
5+
import "os"
6+
7+
// replaceBinary atomically replaces the running binary with a new one.
8+
// On Unix systems, os.Rename over a running binary works because the old
9+
// process keeps the deleted inode open until it exits.
10+
func replaceBinary(newBinaryPath, execPath string) error {
11+
return os.Rename(newBinaryPath, execPath)
12+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//go:build windows
2+
3+
package updater
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
// oldBinarySuffix is the extension used when moving the locked running binary
12+
// out of the way during a Windows update.
13+
const oldBinarySuffix = ".old"
14+
15+
// replaceBinary replaces the running binary on Windows using a rename-dance.
16+
// Windows locks running executables, preventing direct overwrite. However, a
17+
// locked file CAN be renamed. So we:
18+
// 1. Rename the running binary to <name>.old (move it out of the way)
19+
// 2. Rename the new binary into the original path
20+
//
21+
// The .old file is cleaned up on next startup via CleanupOldBinary.
22+
func replaceBinary(newBinaryPath, execPath string) error {
23+
oldPath := execPath + oldBinarySuffix
24+
25+
// Remove any leftover .old file from a previous update
26+
_ = os.Remove(oldPath)
27+
28+
// Step 1: Rename the running (locked) binary out of the way
29+
if err := os.Rename(execPath, oldPath); err != nil {
30+
return fmt.Errorf("moving running binary to %s: %w", filepath.Base(oldPath), err)
31+
}
32+
33+
// Step 2: Rename the new binary into place
34+
if err := os.Rename(newBinaryPath, execPath); err != nil {
35+
// Try to restore the original binary
36+
_ = os.Rename(oldPath, execPath)
37+
return fmt.Errorf("moving new binary into place: %w", err)
38+
}
39+
40+
return nil
41+
}

0 commit comments

Comments
 (0)