Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//
// Tool profiles allow agents to load only the tools they need:
//
// engram mcp → all 14 tools (default)
// engram mcp --tools=agent → 11 tools agents actually use (per skill files)
// engram mcp → all 15 tools (default)
// engram mcp --tools=agent → 12 tools agents actually use (per skill files)
// engram mcp --tools=admin → 3 tools for TUI/CLI (delete, stats, timeline)
// engram mcp --tools=agent,admin → combine profiles
// engram mcp --tools=mem_save,mem_search → individual tool names
Expand Down Expand Up @@ -56,6 +56,7 @@ var ProfileAgent = map[string]bool{
"mem_capture_passive": true, // extract learnings from text — referenced in Gemini/Codex protocol
"mem_save_prompt": true, // save user prompts
"mem_update": true, // update observation by ID — skills say "use mem_update when you have an exact ID to correct"
"mem_promoted": true, // frequently recalled observations — surface important context at session start
}

// ProfileAdmin contains tools for TUI, dashboards, and manual curation
Expand Down Expand Up @@ -575,6 +576,34 @@ Duplicates are automatically detected and skipped — safe to call multiple time
handleCapturePassive(s),
)
}

// ─── mem_promoted (profile: agent, deferred) ────────────────────────
if shouldRegister("mem_promoted", allowlist) {
srv.AddTool(
mcp.NewTool("mem_promoted",
mcp.WithDescription("Get frequently recalled observations that have proven their value through repeated access. Use at session start to surface the most important context."),
mcp.WithDeferLoading(true),
mcp.WithTitleAnnotation("Get Promoted Memories"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(false),
mcp.WithString("project",
mcp.Description("Filter by project name"),
),
mcp.WithString("scope",
mcp.Description("Filter by scope: project (default) or personal"),
),
mcp.WithNumber("min_recalls",
mcp.Description("Minimum recall count threshold (default: 5)"),
),
mcp.WithNumber("limit",
mcp.Description("Max results (default: 7)"),
),
),
handlePromoted(s),
)
}
}

// ─── Tool Handlers ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1021,6 +1050,40 @@ func handleCapturePassive(s *store.Store) server.ToolHandlerFunc {
}
}

func handlePromoted(s *store.Store) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
project, _ := req.GetArguments()["project"].(string)
scope, _ := req.GetArguments()["scope"].(string)
minRecalls := intArg(req, "min_recalls", 5)
limit := intArg(req, "limit", 7)

results, err := s.PromotedObservations(project, scope, minRecalls, limit)
if err != nil {
return mcp.NewToolResultError("Failed to fetch promoted memories: " + err.Error()), nil
}

if len(results) == 0 {
return mcp.NewToolResultText("No promoted memories found."), nil
}

var b strings.Builder
fmt.Fprintf(&b, "Found %d promoted memories:\n\n", len(results))
for i, r := range results {
projectStr := ""
if r.Project != nil {
projectStr = fmt.Sprintf(" | project: %s", *r.Project)
}
preview := truncate(r.Content, 300)
fmt.Fprintf(&b, "[%d] #%d (%s) — %s [recalled %d times]\n %s\n %s%s | scope: %s\n\n",
i+1, r.ID, r.Type, r.Title, r.RecallCount,
preview,
r.CreatedAt, projectStr, r.Scope)
}

return mcp.NewToolResultText(b.String()), nil
}
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

// defaultSessionID returns a project-scoped default session ID.
Expand Down
21 changes: 11 additions & 10 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,8 @@ func TestResolveToolsAgentProfile(t *testing.T) {
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_session_start", "mem_session_end", "mem_get_observation",
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
"mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct"
"mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct"
"mem_promoted", // frequently recalled observations — surface important context at session start
}
for _, tool := range expectedTools {
if !result[tool] {
Expand Down Expand Up @@ -974,12 +975,12 @@ func TestResolveToolsCombinedProfiles(t *testing.T) {
t.Fatal("expected non-nil allowlist for combined profiles")
}

// Should have all 14 tools
// Should have all 15 tools
allTools := []string{
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_session_start", "mem_session_end", "mem_get_observation",
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
"mem_update", "mem_delete", "mem_stats", "mem_timeline",
"mem_update", "mem_promoted", "mem_delete", "mem_stats", "mem_timeline",
}
for _, tool := range allTools {
if !result[tool] {
Expand Down Expand Up @@ -1164,7 +1165,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) {
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_session_start", "mem_session_end", "mem_get_observation",
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
"mem_update", "mem_delete", "mem_stats", "mem_timeline",
"mem_update", "mem_promoted", "mem_delete", "mem_stats", "mem_timeline",
}

for _, name := range allTools {
Expand Down Expand Up @@ -1203,14 +1204,14 @@ func TestNewServerBackwardsCompatible(t *testing.T) {
srv := NewServer(s)
tools := srv.ListTools()

// 11 agent + 3 admin = 14 total
if len(tools) != 14 {
t.Errorf("NewServer should register all 14 tools, got %d", len(tools))
// 12 agent + 3 admin = 15 total
if len(tools) != 15 {
t.Errorf("NewServer should register all 15 tools, got %d", len(tools))
}
}

func TestProfileConsistency(t *testing.T) {
// Verify that agent + admin = all 14 tools
// Verify that agent + admin = all 15 tools
combined := make(map[string]bool)
for tool := range ProfileAgent {
combined[tool] = true
Expand All @@ -1219,8 +1220,8 @@ func TestProfileConsistency(t *testing.T) {
combined[tool] = true
}

if len(combined) != 14 {
t.Errorf("agent + admin should cover all 14 tools, got %d", len(combined))
if len(combined) != 15 {
t.Errorf("agent + admin should cover all 15 tools, got %d", len(combined))
}

// Verify no overlap between profiles
Expand Down
18 changes: 18 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func (s *Server) routes() {
// Stats
s.mux.HandleFunc("GET /stats", s.handleStats)

// Promoted (frequently recalled)
s.mux.HandleFunc("GET /promoted", s.handlePromoted)

// Project migration
s.mux.HandleFunc("POST /projects/migrate", s.handleMigrateProject)

Expand Down Expand Up @@ -493,6 +496,21 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, stats)
}

func (s *Server) handlePromoted(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
scope := r.URL.Query().Get("scope")
minRecalls := queryInt(r, "min_recalls", 5)
limit := queryInt(r, "limit", 7)

obs, err := s.store.PromotedObservations(project, scope, minRecalls, limit)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}

jsonResponse(w, http.StatusOK, obs)
}

// ─── Sync Status ─────────────────────────────────────────────────────────────

func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading