Skip to content

Commit a9d35df

Browse files
feat(store): add recall tracking and promoted observations endpoint
Add read-side instrumentation to observations: recall_count and last_recalled_at columns track how often each observation is retrieved via Search and GetObservation. Frequently recalled observations can be queried through a new /promoted HTTP endpoint and mem_promoted MCP tool. - Add recall_count and last_recalled_at to observations schema - Increment recall_count on Search() and GetObservation() (fire-and-forget) - Split GetObservation into public (with recall) and private (without) to prevent Timeline navigation from inflating counts - Add PromotedObservations() store method with configurable threshold - Add GET /promoted HTTP endpoint with project/scope/min_recalls/limit params - Add mem_promoted MCP tool to agent profile (deferred loading) - Add 4 tests covering recall increment and promotion filtering Closes #95
1 parent fff7d36 commit a9d35df

5 files changed

Lines changed: 335 additions & 36 deletions

File tree

internal/mcp/mcp.go

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
//
77
// Tool profiles allow agents to load only the tools they need:
88
//
9-
// engram mcp → all 14 tools (default)
10-
// engram mcp --tools=agent → 11 tools agents actually use (per skill files)
9+
// engram mcp → all 15 tools (default)
10+
// engram mcp --tools=agent → 12 tools agents actually use (per skill files)
1111
// engram mcp --tools=admin → 3 tools for TUI/CLI (delete, stats, timeline)
1212
// engram mcp --tools=agent,admin → combine profiles
1313
// engram mcp --tools=mem_save,mem_search → individual tool names
@@ -56,6 +56,7 @@ var ProfileAgent = map[string]bool{
5656
"mem_capture_passive": true, // extract learnings from text — referenced in Gemini/Codex protocol
5757
"mem_save_prompt": true, // save user prompts
5858
"mem_update": true, // update observation by ID — skills say "use mem_update when you have an exact ID to correct"
59+
"mem_promoted": true, // frequently recalled observations — surface important context at session start
5960
}
6061

6162
// ProfileAdmin contains tools for TUI, dashboards, and manual curation
@@ -575,6 +576,34 @@ Duplicates are automatically detected and skipped — safe to call multiple time
575576
handleCapturePassive(s),
576577
)
577578
}
579+
580+
// ─── mem_promoted (profile: agent, deferred) ────────────────────────
581+
if shouldRegister("mem_promoted", allowlist) {
582+
srv.AddTool(
583+
mcp.NewTool("mem_promoted",
584+
mcp.WithDescription("Get frequently recalled observations that have proven their value through repeated access. Use at session start to surface the most important context."),
585+
mcp.WithDeferLoading(true),
586+
mcp.WithTitleAnnotation("Get Promoted Memories"),
587+
mcp.WithReadOnlyHintAnnotation(true),
588+
mcp.WithDestructiveHintAnnotation(false),
589+
mcp.WithIdempotentHintAnnotation(true),
590+
mcp.WithOpenWorldHintAnnotation(false),
591+
mcp.WithString("project",
592+
mcp.Description("Filter by project name"),
593+
),
594+
mcp.WithString("scope",
595+
mcp.Description("Filter by scope: project (default) or personal"),
596+
),
597+
mcp.WithNumber("min_recalls",
598+
mcp.Description("Minimum recall count threshold (default: 5)"),
599+
),
600+
mcp.WithNumber("limit",
601+
mcp.Description("Max results (default: 7)"),
602+
),
603+
),
604+
handlePromoted(s),
605+
)
606+
}
578607
}
579608

580609
// ─── Tool Handlers ───────────────────────────────────────────────────────────
@@ -1021,6 +1050,40 @@ func handleCapturePassive(s *store.Store) server.ToolHandlerFunc {
10211050
}
10221051
}
10231052

1053+
func handlePromoted(s *store.Store) server.ToolHandlerFunc {
1054+
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1055+
project, _ := req.GetArguments()["project"].(string)
1056+
scope, _ := req.GetArguments()["scope"].(string)
1057+
minRecalls := intArg(req, "min_recalls", 5)
1058+
limit := intArg(req, "limit", 7)
1059+
1060+
results, err := s.PromotedObservations(project, scope, minRecalls, limit)
1061+
if err != nil {
1062+
return mcp.NewToolResultError("Failed to fetch promoted memories: " + err.Error()), nil
1063+
}
1064+
1065+
if len(results) == 0 {
1066+
return mcp.NewToolResultText("No promoted memories found."), nil
1067+
}
1068+
1069+
var b strings.Builder
1070+
fmt.Fprintf(&b, "Found %d promoted memories:\n\n", len(results))
1071+
for i, r := range results {
1072+
projectStr := ""
1073+
if r.Project != nil {
1074+
projectStr = fmt.Sprintf(" | project: %s", *r.Project)
1075+
}
1076+
preview := truncate(r.Content, 300)
1077+
fmt.Fprintf(&b, "[%d] #%d (%s) — %s [recalled %d times]\n %s\n %s%s | scope: %s\n\n",
1078+
i+1, r.ID, r.Type, r.Title, r.RecallCount,
1079+
preview,
1080+
r.CreatedAt, projectStr, r.Scope)
1081+
}
1082+
1083+
return mcp.NewToolResultText(b.String()), nil
1084+
}
1085+
}
1086+
10241087
// ─── Helpers ─────────────────────────────────────────────────────────────────
10251088

10261089
// defaultSessionID returns a project-scoped default session ID.

internal/mcp/mcp_test.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,8 @@ func TestResolveToolsAgentProfile(t *testing.T) {
929929
"mem_save", "mem_search", "mem_context", "mem_session_summary",
930930
"mem_session_start", "mem_session_end", "mem_get_observation",
931931
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
932-
"mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct"
932+
"mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct"
933+
"mem_promoted", // frequently recalled observations — surface important context at session start
933934
}
934935
for _, tool := range expectedTools {
935936
if !result[tool] {
@@ -974,12 +975,12 @@ func TestResolveToolsCombinedProfiles(t *testing.T) {
974975
t.Fatal("expected non-nil allowlist for combined profiles")
975976
}
976977

977-
// Should have all 14 tools
978+
// Should have all 15 tools
978979
allTools := []string{
979980
"mem_save", "mem_search", "mem_context", "mem_session_summary",
980981
"mem_session_start", "mem_session_end", "mem_get_observation",
981982
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
982-
"mem_update", "mem_delete", "mem_stats", "mem_timeline",
983+
"mem_update", "mem_promoted", "mem_delete", "mem_stats", "mem_timeline",
983984
}
984985
for _, tool := range allTools {
985986
if !result[tool] {
@@ -1164,7 +1165,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) {
11641165
"mem_save", "mem_search", "mem_context", "mem_session_summary",
11651166
"mem_session_start", "mem_session_end", "mem_get_observation",
11661167
"mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt",
1167-
"mem_update", "mem_delete", "mem_stats", "mem_timeline",
1168+
"mem_update", "mem_promoted", "mem_delete", "mem_stats", "mem_timeline",
11681169
}
11691170

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

1206-
// 11 agent + 3 admin = 14 total
1207-
if len(tools) != 14 {
1208-
t.Errorf("NewServer should register all 14 tools, got %d", len(tools))
1207+
// 12 agent + 3 admin = 15 total
1208+
if len(tools) != 15 {
1209+
t.Errorf("NewServer should register all 15 tools, got %d", len(tools))
12091210
}
12101211
}
12111212

12121213
func TestProfileConsistency(t *testing.T) {
1213-
// Verify that agent + admin = all 14 tools
1214+
// Verify that agent + admin = all 15 tools
12141215
combined := make(map[string]bool)
12151216
for tool := range ProfileAgent {
12161217
combined[tool] = true
@@ -1219,8 +1220,8 @@ func TestProfileConsistency(t *testing.T) {
12191220
combined[tool] = true
12201221
}
12211222

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

12261227
// Verify no overlap between profiles

internal/server/server.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ func (s *Server) routes() {
131131
// Stats
132132
s.mux.HandleFunc("GET /stats", s.handleStats)
133133

134+
// Promoted (frequently recalled)
135+
s.mux.HandleFunc("GET /promoted", s.handlePromoted)
136+
134137
// Project migration
135138
s.mux.HandleFunc("POST /projects/migrate", s.handleMigrateProject)
136139

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

499+
func (s *Server) handlePromoted(w http.ResponseWriter, r *http.Request) {
500+
project := r.URL.Query().Get("project")
501+
scope := r.URL.Query().Get("scope")
502+
minRecalls := queryInt(r, "min_recalls", 5)
503+
limit := queryInt(r, "limit", 7)
504+
505+
obs, err := s.store.PromotedObservations(project, scope, minRecalls, limit)
506+
if err != nil {
507+
jsonError(w, http.StatusInternalServerError, err.Error())
508+
return
509+
}
510+
511+
jsonResponse(w, http.StatusOK, obs)
512+
}
513+
496514
// ─── Sync Status ─────────────────────────────────────────────────────────────
497515

498516
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)