Skip to content

Commit e17a71e

Browse files
committed
feat: user profiles (SuperMemory-inspired)
Auto-maintained static facts + dynamic recent context. One call, ~50ms. Inject into system prompt. - profile.Build(): extracts from memory graph (no LLM) - Static: conventions, decisions, preferences (high confidence) - Dynamic: recent tasks, bugs, sessions (last 7 days) - Stack: auto-detected from entity nodes - profile.Format(): markdown for agent injection - profile.Merge(): combine project + global profiles - REST: GET /yaad/profile - MCP: yaad_profile tool - 23/23 tests passing
1 parent 0cf06bd commit e17a71e

5 files changed

Lines changed: 218 additions & 0 deletions

File tree

integration_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,32 @@ func TestGitSync(t *testing.T) {
553553
}
554554
}
555555

556+
func TestUserProfile(t *testing.T) {
557+
eng, cleanup := setup(t)
558+
defer cleanup()
559+
560+
eng.Remember(engine.RememberInput{Type: "convention", Content: "Use jose for JWT auth", Scope: "project"})
561+
eng.Remember(engine.RememberInput{Type: "decision", Content: "Chose NATS for event bus", Scope: "project"})
562+
eng.Remember(engine.RememberInput{Type: "task", Content: "Add rate limiting", Scope: "project"})
563+
eng.Remember(engine.RememberInput{Type: "preference", Content: "Prefers functional style", Scope: "project"})
564+
565+
p, err := eng.Profile("")
566+
if err != nil {
567+
t.Fatal(err)
568+
}
569+
if len(p.Static) == 0 {
570+
t.Error("profile: no static facts")
571+
}
572+
if p.Summary == "" {
573+
t.Error("profile: empty summary")
574+
}
575+
formatted := p.Format()
576+
if !strings.Contains(formatted, "User Profile") {
577+
t.Error("profile: formatted output missing header")
578+
}
579+
t.Logf("Profile:\n%s", formatted)
580+
}
581+
556582
func TestConflictResolver(t *testing.T) {
557583
eng, cleanup := setup(t)
558584
defer cleanup()

internal/engine/memory.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/GrayCodeAI/yaad/internal/intent"
1515
"github.com/GrayCodeAI/yaad/internal/mental"
1616
"github.com/GrayCodeAI/yaad/internal/privacy"
17+
"github.com/GrayCodeAI/yaad/internal/profile"
1718
"github.com/GrayCodeAI/yaad/internal/storage"
1819
"github.com/GrayCodeAI/yaad/internal/temporal"
1920
)
@@ -315,6 +316,11 @@ func (e *Engine) MentalModel(project string) (*mental.Model, error) {
315316
return mental.Generate(e.store, project)
316317
}
317318

319+
// Profile returns an auto-maintained user/project profile (static facts + dynamic context).
320+
func (e *Engine) Profile(project string) (*profile.Profile, error) {
321+
return profile.Build(e.store, project)
322+
}
323+
318324
func (e *Engine) Status(project string) (*Status, error) {
319325
nodes, err := e.store.ListNodes(storage.NodeFilter{Project: project})
320326
if err != nil {

internal/profile/profile.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Package profile implements auto-maintained user/project profiles.
2+
// Inspired by SuperMemory's profile system: static facts + dynamic recent context.
3+
//
4+
// A profile is automatically built from the memory graph:
5+
// - Static: long-term facts (conventions, decisions, preferences) — stable over weeks
6+
// - Dynamic: recent activity (active tasks, recent bugs, last session) — changes daily
7+
//
8+
// One call, ~50ms. Inject into system prompt and the agent instantly knows the project.
9+
package profile
10+
11+
import (
12+
"fmt"
13+
"sort"
14+
"strings"
15+
"time"
16+
17+
"github.com/GrayCodeAI/yaad/internal/storage"
18+
)
19+
20+
// Profile is an auto-maintained project/user profile.
21+
type Profile struct {
22+
Project string `json:"project"`
23+
Static []string `json:"static"` // long-term facts (conventions, decisions, preferences)
24+
Dynamic []string `json:"dynamic"` // recent activity (tasks, bugs, last session)
25+
Stack []string `json:"stack"` // detected tech stack
26+
Summary string `json:"summary"` // one-line project summary
27+
}
28+
29+
// Build generates a profile from the memory graph. No LLM needed.
30+
func Build(store *storage.Store, project string) (*Profile, error) {
31+
p := &Profile{Project: project}
32+
33+
// Static: high-confidence conventions, decisions, preferences
34+
for _, typ := range []string{"convention", "decision", "preference"} {
35+
nodes, _ := store.ListNodes(storage.NodeFilter{
36+
Type: typ, Project: project, MinConfidence: 0.5,
37+
})
38+
for _, n := range nodes {
39+
p.Static = append(p.Static, n.Content)
40+
}
41+
}
42+
43+
// Dynamic: recent tasks, bugs, sessions (last 7 days)
44+
cutoff := time.Now().AddDate(0, 0, -7)
45+
for _, typ := range []string{"task", "bug", "session"} {
46+
nodes, _ := store.ListNodes(storage.NodeFilter{
47+
Type: typ, Project: project, MinConfidence: 0.1,
48+
})
49+
for _, n := range nodes {
50+
if n.CreatedAt.After(cutoff) || n.UpdatedAt.After(cutoff) {
51+
p.Dynamic = append(p.Dynamic, fmt.Sprintf("[%s] %s", n.Type, n.Content))
52+
}
53+
}
54+
}
55+
56+
// Sort dynamic by recency (most recent first)
57+
// Already in insertion order which is roughly chronological
58+
59+
// Stack: extract from entity nodes
60+
entities, _ := store.ListNodes(storage.NodeFilter{
61+
Type: "entity", Project: project,
62+
})
63+
for _, n := range entities {
64+
if isTech(n.Content) {
65+
p.Stack = append(p.Stack, n.Content)
66+
}
67+
}
68+
69+
// Deduplicate stack
70+
p.Stack = dedup(p.Stack)
71+
72+
// Summary
73+
parts := []string{}
74+
if len(p.Stack) > 0 {
75+
parts = append(parts, "Stack: "+strings.Join(p.Stack[:min(len(p.Stack), 5)], ", "))
76+
}
77+
parts = append(parts, fmt.Sprintf("%d facts", len(p.Static)))
78+
if len(p.Dynamic) > 0 {
79+
parts = append(parts, fmt.Sprintf("%d recent items", len(p.Dynamic)))
80+
}
81+
p.Summary = strings.Join(parts, " · ")
82+
83+
return p, nil
84+
}
85+
86+
// Format returns the profile as markdown for agent injection.
87+
func (p *Profile) Format() string {
88+
var sb strings.Builder
89+
sb.WriteString("## User Profile\n\n")
90+
91+
if p.Summary != "" {
92+
sb.WriteString("**" + p.Summary + "**\n\n")
93+
}
94+
95+
if len(p.Static) > 0 {
96+
sb.WriteString("### What I Know (stable)\n")
97+
for _, s := range p.Static[:min(len(p.Static), 10)] {
98+
sb.WriteString("- " + s + "\n")
99+
}
100+
sb.WriteString("\n")
101+
}
102+
103+
if len(p.Dynamic) > 0 {
104+
sb.WriteString("### What's Happening (recent)\n")
105+
for _, d := range p.Dynamic[:min(len(p.Dynamic), 5)] {
106+
sb.WriteString("- " + d + "\n")
107+
}
108+
sb.WriteString("\n")
109+
}
110+
111+
return sb.String()
112+
}
113+
114+
// Merge combines two profiles (e.g., project + global).
115+
func Merge(a, b *Profile) *Profile {
116+
return &Profile{
117+
Project: a.Project,
118+
Static: dedup(append(a.Static, b.Static...)),
119+
Dynamic: append(a.Dynamic, b.Dynamic...),
120+
Stack: dedup(append(a.Stack, b.Stack...)),
121+
Summary: a.Summary,
122+
}
123+
}
124+
125+
func isTech(name string) bool {
126+
techs := map[string]bool{
127+
"typescript": true, "javascript": true, "python": true, "go": true, "rust": true,
128+
"react": true, "vue": true, "next": true, "node": true, "deno": true, "bun": true,
129+
"postgresql": true, "mysql": true, "sqlite": true, "redis": true, "nats": true,
130+
"docker": true, "kubernetes": true, "aws": true, "gcp": true, "azure": true,
131+
"jose": true, "express": true, "fastify": true, "gin": true, "fiber": true,
132+
"tailwind": true, "prisma": true, "drizzle": true, "trpc": true, "graphql": true,
133+
}
134+
return techs[strings.ToLower(name)]
135+
}
136+
137+
func dedup(items []string) []string {
138+
seen := map[string]bool{}
139+
var out []string
140+
for _, item := range items {
141+
lower := strings.ToLower(item)
142+
if !seen[lower] {
143+
seen[lower] = true
144+
out = append(out, item)
145+
}
146+
}
147+
return out
148+
}
149+
150+
func min(a, b int) int {
151+
if a < b {
152+
return a
153+
}
154+
return b
155+
}
156+
157+
// ensure sort is used
158+
var _ = sort.Strings

internal/server/mcp.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ func (s *MCPServer) registerTools() {
134134
mcp.WithDescription("Classify query intent (Why/When/Who/How/What/General) for intent-aware retrieval"),
135135
mcp.WithString("query", mcp.Required(), mcp.Description("Query to classify")),
136136
), s.handleIntent)
137+
138+
// yaad_profile
139+
add(mcp.NewTool("yaad_profile",
140+
mcp.WithDescription("Get auto-maintained user/project profile: static facts + dynamic recent context"),
141+
mcp.WithString("project", mcp.Description("Project path")),
142+
), s.handleProfile)
137143
}
138144

139145
// --- Tool handlers ---
@@ -274,6 +280,17 @@ func (s *MCPServer) handleIntent(_ context.Context, req mcp.CallToolRequest) (*m
274280
})
275281
}
276282

283+
func (s *MCPServer) handleProfile(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
284+
p, err := s.eng.Profile(strArg(req, "project"))
285+
if err != nil {
286+
return nil, err
287+
}
288+
return jsonResult(map[string]any{
289+
"profile": p,
290+
"formatted": p.Format(),
291+
})
292+
}
293+
277294
// --- helpers ---
278295

279296
func strArg(req mcp.CallToolRequest, key string) string {

internal/server/rest.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ func (s *RESTServer) RegisterRoutes(mux *http.ServeMux) {
102102
mux.HandleFunc("POST /yaad/bench", s.handleBench)
103103
mux.HandleFunc("POST /yaad/compact", s.handleCompact)
104104
mux.HandleFunc("GET /yaad/mental-model", s.handleMentalModel)
105+
mux.HandleFunc("GET /yaad/profile", s.handleProfile)
105106
}
106107

107108
func (s *RESTServer) handleRemember(w http.ResponseWriter, r *http.Request) {
@@ -560,6 +561,16 @@ func (s *RESTServer) handleMentalModel(w http.ResponseWriter, r *http.Request) {
560561
httpJSON(w, map[string]any{"model": model, "formatted": model.Format()}, 200)
561562
}
562563

564+
func (s *RESTServer) handleProfile(w http.ResponseWriter, r *http.Request) {
565+
project := r.URL.Query().Get("project")
566+
p, err := s.eng.Profile(project)
567+
if err != nil {
568+
httpErr(w, err, 500)
569+
return
570+
}
571+
httpJSON(w, map[string]any{"profile": p, "formatted": p.Format()}, 200)
572+
}
573+
563574
// --- helpers ---
564575

565576
func httpJSON(w http.ResponseWriter, v any, code int) {

0 commit comments

Comments
 (0)