Skip to content

Commit 0480b96

Browse files
committed
feat: 8 improvements from top 20 OSS comparison
Based on deep analysis of Mem0, Letta, Cognee, memU, MemOS, Hindsight, Engram, agentmemory, Zep, and memvid: 1. Memory conflict resolution (Mem0-inspired) - Detect contradictions: same topic, different facts - Auto-supersede old with new, lower old confidence - Audit trail via node_versions 2. Memory compaction (Engram/Letta-inspired) - Auto-summarize when graph exceeds token budget - Merge low-confidence, rarely-accessed nodes by type - Keep anchors (file/entity/session) intact 3. Temporal backbone (MAGMA/Zep Graphiti-inspired) - Immutable timeline chain per project - Every node auto-linked to previous via learned_in edge - Timeline traversal: walk forward/backward 4. Dedup rolling window (Engram/agentmemory-inspired) - Skip near-duplicates within configurable time window (default 5min) - SHA-256 hash with whitespace normalization 5. Topic dedup (Engram-inspired) - UpsertByTopic: same project+scope+topic_key updates existing node - Version history preserved on update 6. SQLite encryption at rest (Engram E2E-inspired) - AES-256-GCM file-level encryption - GenerateKey, EncryptFile, DecryptFile, IsEncrypted 7. Mental models (Hindsight-inspired) - Auto-generated project summary from high-confidence nodes - Stack detection, conventions, active tasks, key decisions - Format() returns markdown for agent injection 8. TypeScript SDK (@graycode/yaad) - Full REST API wrapper: remember, recall, context, link, impact - Session management, feedback, SSE events - Ready for npm publish
1 parent f9cdf23 commit 0480b96

10 files changed

Lines changed: 936 additions & 0 deletions

File tree

internal/compact/compact.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Package compact implements memory compaction — auto-summarize when
2+
// the graph exceeds a token budget. Based on Engram and Letta approaches.
3+
package compact
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
9+
"github.com/google/uuid"
10+
"github.com/GrayCodeAI/yaad/internal/storage"
11+
)
12+
13+
// Compactor summarizes old, low-confidence memories to keep the graph lean.
14+
type Compactor struct {
15+
store *storage.Store
16+
maxTokens int // max total tokens before compaction triggers
17+
}
18+
19+
func New(store *storage.Store, maxTokens int) *Compactor {
20+
if maxTokens <= 0 {
21+
maxTokens = 50000 // ~200KB of content
22+
}
23+
return &Compactor{store: store, maxTokens: maxTokens}
24+
}
25+
26+
// NeedsCompaction returns true if total content exceeds the token budget.
27+
func (c *Compactor) NeedsCompaction(project string) (bool, int) {
28+
nodes, _ := c.store.ListNodes(storage.NodeFilter{Project: project})
29+
totalTokens := 0
30+
for _, n := range nodes {
31+
totalTokens += len(n.Content) / 4 // ~4 chars per token
32+
}
33+
return totalTokens > c.maxTokens, totalTokens
34+
}
35+
36+
// Compact merges low-confidence, old memories of the same type into summary nodes.
37+
// Returns the number of nodes compacted.
38+
func (c *Compactor) Compact(project string) (int, error) {
39+
nodes, err := c.store.ListNodes(storage.NodeFilter{Project: project})
40+
if err != nil {
41+
return 0, err
42+
}
43+
44+
// Group by type
45+
byType := map[string][]*storage.Node{}
46+
for _, n := range nodes {
47+
if n.Type == "file" || n.Type == "entity" || n.Type == "session" {
48+
continue // don't compact anchors or sessions
49+
}
50+
if n.Confidence < 0.5 && n.AccessCount < 3 {
51+
byType[n.Type] = append(byType[n.Type], n)
52+
}
53+
}
54+
55+
compacted := 0
56+
for typ, group := range byType {
57+
if len(group) < 3 {
58+
continue // not enough to compact
59+
}
60+
61+
// Build summary from group
62+
var contents []string
63+
var ids []string
64+
for _, n := range group {
65+
contents = append(contents, n.Content)
66+
ids = append(ids, n.ID)
67+
}
68+
69+
summary := buildCompactSummary(typ, contents)
70+
71+
// Create summary node
72+
summaryNode := &storage.Node{
73+
ID: uuid.New().String(),
74+
Type: typ,
75+
Content: summary,
76+
ContentHash: fmt.Sprintf("compact:%s:%d", typ, len(ids)),
77+
Summary: fmt.Sprintf("Compacted %d %s memories", len(ids), typ),
78+
Scope: group[0].Scope,
79+
Project: project,
80+
Tier: 3, // cold
81+
Confidence: 0.6,
82+
Version: 1,
83+
}
84+
if err := c.store.CreateNode(summaryNode); err != nil {
85+
continue
86+
}
87+
88+
// Archive compacted nodes
89+
for _, id := range ids {
90+
old, _ := c.store.GetNode(id)
91+
if old != nil {
92+
c.store.SaveVersion(old.ID, old.Content, "compactor", "compacted into "+summaryNode.ID[:8])
93+
old.Confidence = 0
94+
c.store.UpdateNode(old)
95+
compacted++
96+
}
97+
}
98+
}
99+
return compacted, nil
100+
}
101+
102+
func buildCompactSummary(typ string, contents []string) string {
103+
// Take first 5 items as representative
104+
limit := 5
105+
if len(contents) < limit {
106+
limit = len(contents)
107+
}
108+
var sb strings.Builder
109+
sb.WriteString(fmt.Sprintf("Summary of %d %s memories:\n", len(contents), typ))
110+
for i, c := range contents[:limit] {
111+
if len(c) > 100 {
112+
c = c[:100] + "..."
113+
}
114+
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, c))
115+
}
116+
if len(contents) > limit {
117+
sb.WriteString(fmt.Sprintf("... and %d more\n", len(contents)-limit))
118+
}
119+
return sb.String()
120+
}

internal/conflict/resolver.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Package conflict detects and resolves contradictory memories.
2+
// When a new memory contradicts an existing one (same entity/topic, different fact),
3+
// the old memory is auto-superseded. Based on Mem0's conflict resolution approach.
4+
package conflict
5+
6+
import (
7+
"strings"
8+
9+
"github.com/google/uuid"
10+
"github.com/GrayCodeAI/yaad/internal/storage"
11+
)
12+
13+
// Resolver detects and resolves memory conflicts.
14+
type Resolver struct {
15+
store *storage.Store
16+
}
17+
18+
func New(store *storage.Store) *Resolver {
19+
return &Resolver{store: store}
20+
}
21+
22+
// CheckAndResolve checks if a new node contradicts existing nodes.
23+
// If a contradiction is found, creates a supersedes edge and lowers the old node's confidence.
24+
// Returns the list of superseded node IDs.
25+
func (r *Resolver) CheckAndResolve(newNode *storage.Node) ([]string, error) {
26+
// Find existing nodes of the same type in the same project
27+
existing, err := r.store.ListNodes(storage.NodeFilter{
28+
Type: newNode.Type,
29+
Project: newNode.Project,
30+
Scope: newNode.Scope,
31+
})
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
var superseded []string
37+
for _, old := range existing {
38+
if old.ID == newNode.ID || old.Confidence <= 0 {
39+
continue
40+
}
41+
if isContradiction(newNode, old) {
42+
// Create supersedes edge
43+
r.store.CreateEdge(&storage.Edge{
44+
ID: uuid.New().String(),
45+
FromID: newNode.ID,
46+
ToID: old.ID,
47+
Type: "supersedes",
48+
Acyclic: true,
49+
Weight: 1.0,
50+
})
51+
// Lower old node confidence
52+
old.Confidence *= 0.3
53+
r.store.UpdateNode(old)
54+
// Save version for audit trail
55+
r.store.SaveVersion(old.ID, old.Content, "conflict-resolver",
56+
"superseded by "+newNode.ID[:8])
57+
superseded = append(superseded, old.ID)
58+
}
59+
}
60+
return superseded, nil
61+
}
62+
63+
// isContradiction detects if two nodes about the same topic say different things.
64+
func isContradiction(newNode, oldNode *storage.Node) bool {
65+
// Extract key entities from both
66+
newEntities := extractKeyTerms(newNode.Content)
67+
oldEntities := extractKeyTerms(oldNode.Content)
68+
69+
// Must share at least 2 key terms (same topic)
70+
shared := 0
71+
for term := range newEntities {
72+
if oldEntities[term] {
73+
shared++
74+
}
75+
}
76+
if shared < 2 {
77+
return false // different topics, not a contradiction
78+
}
79+
80+
// Check for negation patterns
81+
newLower := strings.ToLower(newNode.Content)
82+
83+
// "Use X" vs "Don't use X" or "Use Y instead of X"
84+
if strings.Contains(newLower, "instead of") || strings.Contains(newLower, "not ") ||
85+
strings.Contains(newLower, "replaced") || strings.Contains(newLower, "switched") ||
86+
strings.Contains(newLower, "migrated") || strings.Contains(newLower, "changed") {
87+
return true
88+
}
89+
90+
// "Chose X" vs "Chose Y" for same topic (shared entities but different choice)
91+
if newNode.Type == "decision" && oldNode.Type == "decision" && shared >= 2 {
92+
// Different content but same topic = likely updated decision
93+
if newNode.Content != oldNode.Content {
94+
return true
95+
}
96+
}
97+
98+
// Same convention type, same entities, different content = updated convention
99+
if newNode.Type == "convention" && oldNode.Type == "convention" && shared >= 2 {
100+
if newNode.Content != oldNode.Content {
101+
return true
102+
}
103+
}
104+
105+
return false
106+
}
107+
108+
func extractKeyTerms(content string) map[string]bool {
109+
terms := map[string]bool{}
110+
words := strings.Fields(strings.ToLower(content))
111+
for _, w := range words {
112+
w = strings.Trim(w, ".,;:!?\"'()[]{}")
113+
if len(w) > 3 && !isStopWord(w) {
114+
terms[w] = true
115+
}
116+
}
117+
return terms
118+
}
119+
120+
var stopWords = map[string]bool{
121+
"the": true, "and": true, "for": true, "are": true, "but": true,
122+
"not": true, "you": true, "all": true, "can": true, "had": true,
123+
"was": true, "one": true, "our": true, "use": true, "with": true,
124+
"this": true, "that": true, "from": true, "they": true, "will": true,
125+
"have": true, "been": true, "should": true, "would": true, "could": true,
126+
"also": true, "than": true, "then": true, "into": true, "over": true,
127+
}
128+
129+
func isStopWord(w string) bool { return stopWords[w] }

internal/dedup/window.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Package dedup implements rolling-window deduplication.
2+
// Skips near-duplicate memories within a configurable time window.
3+
// Based on Engram's SHA-256 dedup with 15-minute rolling window
4+
// and agentmemory's 5-minute dedup window.
5+
package dedup
6+
7+
import (
8+
"crypto/sha256"
9+
"fmt"
10+
"sync"
11+
"time"
12+
)
13+
14+
// Window tracks recent content hashes to skip near-duplicates.
15+
type Window struct {
16+
duration time.Duration
17+
seen map[string]time.Time
18+
mu sync.Mutex
19+
}
20+
21+
// New creates a dedup window. Default: 5 minutes.
22+
func New(duration time.Duration) *Window {
23+
if duration <= 0 {
24+
duration = 5 * time.Minute
25+
}
26+
return &Window{duration: duration, seen: map[string]time.Time{}}
27+
}
28+
29+
// IsDuplicate returns true if content was seen within the rolling window.
30+
// If not a duplicate, records it for future checks.
31+
func (w *Window) IsDuplicate(content string) bool {
32+
hash := contentHash(content)
33+
34+
w.mu.Lock()
35+
defer w.mu.Unlock()
36+
37+
// Clean expired entries
38+
now := time.Now()
39+
for k, t := range w.seen {
40+
if now.Sub(t) > w.duration {
41+
delete(w.seen, k)
42+
}
43+
}
44+
45+
// Check if seen recently
46+
if _, exists := w.seen[hash]; exists {
47+
return true
48+
}
49+
50+
w.seen[hash] = now
51+
return false
52+
}
53+
54+
// NormalizedHash returns a hash that ignores whitespace differences.
55+
func contentHash(content string) string {
56+
h := sha256.Sum256([]byte(normalizeWhitespace(content)))
57+
return fmt.Sprintf("%x", h[:16])
58+
}
59+
60+
func normalizeWhitespace(s string) string {
61+
var result []byte
62+
space := false
63+
for _, b := range []byte(s) {
64+
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
65+
if !space {
66+
result = append(result, ' ')
67+
space = true
68+
}
69+
} else {
70+
result = append(result, b)
71+
space = false
72+
}
73+
}
74+
return string(result)
75+
}

0 commit comments

Comments
 (0)