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
56 changes: 56 additions & 0 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func ensureImplicitSessionWithCWD(s *store.Store, sessionID, project string) err
var ProfileAgent = map[string]bool{
"mem_save": true, // proactive save — referenced 17 times across protocols
"mem_search": true, // search past memories — referenced 6 times
"mem_find_project": true, // find projects by memory content
"mem_context": true, // recent context from previous sessions — referenced 10 times
"mem_session_summary": true, // end-of-session summary — referenced 16 times
"mem_session_start": true, // register session start
Expand Down Expand Up @@ -295,6 +296,28 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli
)
}

// ─── mem_find_project ─────────────────────────────────────────────
if shouldRegister("mem_find_project", allowlist) {
srv.AddTool(
mcp.NewTool("mem_find_project",
mcp.WithDescription("Search for projects containing relevant memories. Use this when you don't know which project holds a past decision. It returns the top matching projects, their match counts, and rank. You can then use mem_search with a specific project name to read those memories."),
mcp.WithTitleAnnotation("Find Projects"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(false),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query — natural language or keywords to find across all projects"),
),
mcp.WithString("match_mode",
mcp.Description("Token matching: \"all\" (default — every token must match, FTS5 AND) or \"any\" (any token matches)."),
),
),
handleFindProject(s, cfg),
)
}

// ─── mem_save (profile: agent, core — always in context) ───────────
if shouldRegister("mem_save", allowlist) {
srv.AddTool(
Expand Down Expand Up @@ -1148,6 +1171,39 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv
}
}

func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query, _ := req.GetArguments()["query"].(string)
matchMode, _ := req.GetArguments()["match_mode"].(string)

if query == "" {
return mcp.NewToolResultError("query is required"), nil
}
if matchMode != "" && matchMode != "all" && matchMode != "any" {
return mcp.NewToolResultError(fmt.Sprintf("invalid match_mode %q: must be \"all\" or \"any\"", matchMode)), nil
}

limit := 10 // Fix limit as requested by minimalist approach
matches, err := s.SearchProjects(query, matchMode, limit)
if err != nil {
return mcp.NewToolResultError("Project search failed: " + err.Error()), nil
}

if len(matches) == 0 {
return mcp.NewToolResultText(fmt.Sprintf("No projects found matching %q.", query)), nil
}

var b strings.Builder
fmt.Fprintf(&b, "Found %d project(s) matching %q:\n", len(matches), query)
for _, m := range matches {
fmt.Fprintf(&b, "- %s (%d matches, rank: %.2f)\n", m.Project, m.MatchCount, m.TopRank)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate mcp.go =="
fd -a 'mcp\.go$' . | sed 's#^\./##'

echo "== context around line 1199 =="
if [ -f internal/mcp/mcp.go ]; then
  nl -ba internal/mcp/mcp.go | sed -n '1160,1220p'
fi

echo "== TopRank definitions/usages =="
rg -n "TopRank|MatchCount|Project|Rank" internal/mcp/mcp.go | sed -n '1,220p'

echo "== behavioral probe for %.2f vs %.6g precision loss =="
python3 - <<'PY'
samples = [1.0000001, 1.0000002, 1.0000012, 1.0000013, 2.1234567, 2.1234568]
print("input -> %.2f -> %.6g")
for x in samples:
    print(f"{x:.7f} {x:.2f} {x:.6g}")
print("collapsed distinct values for %.2f:", len(set(f"{x:.2f}" for x in samples)))
print("collapses for %.6g:", len(set(f"{x:.6g}" for x in samples)))
PY

Repository: Gentleman-Programming/engram

Length of output: 296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== mcp.go relevant definitions =="
rg -n "type .*struct|func .*TopRank|TopRank|MatchCount|Project" internal/mcp/mcp.go -C 3 | sed -n '1,260p'

echo "== compute/top-ranked value generation =="
rg -n "TopRank|MatchCount|Project" internal/mcp/mcp.go -C 5 | sed -n '1,260p'

Repository: Gentleman-Programming/engram

Length of output: 22281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SearchProjects definitions/usages =="
rg -n "SearchProjects|SearchProject|TopRank|MatchCount" . -C 4 --include='*.go' | sed -n '1,260p'

echo "== Precision loss examples =="
python3 - <<'PY'
samples = [1.0000001, 1.0000002, 1.0000012, 1.0000013, 2.1234567, 2.1234568, -0.001, -0.0015]
fmt2 = [f"{x:.2f}" for x in samples]
fmt6g = [f"{x:.6g}" for x in samples]
print("input -> %.2f -> %.6g")
for x, a, b in zip(samples, fmt2, fmt6g):
    print(f"{x:.8f} {a} {b}")
print("distinct %.2f:", len(set(fmt2)), f"{', '.join(set(fmt2))}" if len(set(fmt2)) < 8 else "")
print("distinct %.6g:", len(set(fmt6g)))
PY

Repository: Gentleman-Programming/engram

Length of output: 648


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SearchProjects definitions/usages =="
rg -n "SearchProjects|SearchProject|TopRank|MatchCount" . --glob '*.go' -C 4 | sed -n '1,280p'

echo "== Precision loss examples =="
python3 - <<'PY'
samples = [1.0000001, 1.0000002, 1.0000012, 1.0000013, 2.1234567, 2.1234568, -0.001, -0.0015]
fmt2 = [f"{x:.2f}" for x in samples]
fmt6g = [f"{x:.6g}" for x in samples]
print("input -> %.2f -> %.6g")
for x, a, b in zip(samples, fmt2, fmt6g):
    print(f"{x:.8f} {a} {b}")
print("distinct %.2f:", len(set(fmt2)))
print("distinct %.6g:", len(set(fmt6g)))
PY

Repository: Gentleman-Programming/engram

Length of output: 5421


Preserve usable TopRank precision.

%.2f can collapse nearby computed ranks to the same displayed value, so mem_find_project does not return the actual TopRank. Use a precision-preserving format or structured numeric metadata.

Proposed fix
- fmt.Fprintf(&b, "- %s (%d matches, rank: %.2f)\n", m.Project, m.MatchCount, m.TopRank)
+ fmt.Fprintf(&b, "- %s (%d matches, rank: %.6g)\n", m.Project, m.MatchCount, m.TopRank)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fmt.Fprintf(&b, "- %s (%d matches, rank: %.2f)\n", m.Project, m.MatchCount, m.TopRank)
fmt.Fprintf(&b, "- %s (%d matches, rank: %.6g)\n", m.Project, m.MatchCount, m.TopRank)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/mcp/mcp.go` at line 1199, Update the result formatting in the
mem_find_project output around TopRank so nearby computed ranks remain
distinguishable and the returned value preserves sufficient numeric precision;
replace the fixed two-decimal formatting in the fmt.Fprintf call with a
precision-preserving representation while keeping the existing project and
match-count fields unchanged.

}
b.WriteString("\nUse mem_search with project: \"<name>\" to explore these memories.")

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

func handlePin(s *store.Store, pinned bool) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := int64(intArg(req, "id", 0))
Expand Down
24 changes: 12 additions & 12 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,7 @@ func TestResolveToolsAgentProfile(t *testing.T) {
}

expectedTools := []string{
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_save", "mem_search", "mem_find_project", "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"
Expand Down Expand Up @@ -2254,7 +2254,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) {
tools := srv.ListTools()

allTools := []string{
"mem_save", "mem_search", "mem_context", "mem_session_summary",
"mem_save", "mem_search", "mem_find_project", "mem_context", "mem_session_summary",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Test mem_find_project behavior, not only registration.

The new assertions only prove the tool is listed. Add deterministic handler tests for a successful search result, missing query, invalid match_mode, no results, and store failures.

As per path instructions, **/*_test.go must cover happy paths, error paths, and edge cases, and behavior changes without tests should be blocked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/mcp/mcp_test.go` at line 2257, Expand the tests around
mem_find_project beyond tool registration by invoking its handler with
deterministic fixtures. Cover a successful search result, missing query, invalid
match_mode, no results, and store failures, asserting each response and error
behavior; keep the existing registration assertion intact.

Source: Path instructions

"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_merge_projects",
Expand Down Expand Up @@ -2364,14 +2364,14 @@ func TestNewServerBackwardsCompatible(t *testing.T) {
srv := NewServer(s)
tools := srv.ListTools()

// 18 agent + 4 admin = 22 total.
if len(tools) != 22 {
t.Errorf("NewServer should register all 22 tools, got %d", len(tools))
// 19 agent + 4 admin = 23 total.
if len(tools) != 23 {
t.Errorf("NewServer should register all 23 tools, got %d", len(tools))
}
}

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

// 18 agent + 4 admin = 22 total.
if len(combined) != 22 {
t.Errorf("agent + admin should cover all 22 tools, got %d", len(combined))
// 19 agent + 4 admin = 23 total.
if len(combined) != 23 {
t.Errorf("agent + admin should cover all 23 tools, got %d", len(combined))
}

// Verify no overlap between profiles
Expand Down Expand Up @@ -2710,9 +2710,9 @@ func TestNewServerWithConfig(t *testing.T) {
t.Fatal("expected MCP server instance")
}
tools := srv.ListTools()
// Should have all 22 tools (18 agent + 4 admin).
if len(tools) != 22 {
t.Errorf("NewServerWithConfig should register all 22 tools, got %d", len(tools))
// Should have all 23 tools (19 agent + 4 admin).
if len(tools) != 23 {
t.Errorf("NewServerWithConfig should register all 23 tools, got %d", len(tools))
}
}

Expand Down
59 changes: 59 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ type SearchOptions struct {
MatchMode string `json:"match_mode,omitempty"` // "all" (default) | "any"
}

type ProjectMatch struct {
Project string
MatchCount int
TopRank float64
}

type AddObservationParams struct {
SessionID string `json:"session_id"`
Type string `json:"type"`
Expand Down Expand Up @@ -3235,6 +3241,59 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error)
return results, nil
}

// ─── Search Projects ────────────────────────────────────────────────────────

// SearchProjects groups FTS5 search results by project to help route ambiguous searches.
func (s *Store) SearchProjects(query string, matchMode string, limit int) ([]ProjectMatch, error) {
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}

var ftsQuery string
if matchMode == "any" {
ftsQuery = sanitizeFTSCandidates(query)
} else {
ftsQuery = sanitizeFTS(query)
}
if ftsQuery == "" {
return []ProjectMatch{}, nil
}

sqlQ := `
SELECT project, COUNT(id) as match_count, MIN(rank) as top_rank
FROM (
SELECT o.project, o.id, observations_fts.rank as rank
FROM observations_fts
JOIN observations o ON o.id = observations_fts.rowid
WHERE observations_fts MATCH ? AND o.deleted_at IS NULL AND o.project != ''
)
GROUP BY project
ORDER BY top_rank ASC, match_count DESC, project ASC
LIMIT ?
`
rows, err := s.queryItHook(s.db, sqlQ, ftsQuery, limit)
if err != nil {
return nil, fmt.Errorf("search projects: %w", err)
}
defer rows.Close()

var matches []ProjectMatch
for rows.Next() {
var p ProjectMatch
if err := rows.Scan(&p.Project, &p.MatchCount, &p.TopRank); err != nil {
return nil, err
}
matches = append(matches, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return matches, nil
}

// ─── Stats ───────────────────────────────────────────────────────────────────

func (s *Store) Stats() (*Stats, error) {
Expand Down
74 changes: 74 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8830,3 +8830,77 @@ func TestSanitizeFTS(t *testing.T) {
})
}
}

func TestSearchProjects(t *testing.T) {
st := newTestStore(t)

st.CreateSession("s1", "project-a", "/tmp/a")
st.CreateSession("s2", "project-b", "/tmp/b")
st.CreateSession("s3", "project-c", "/tmp/c")

// Seed 3 observations for project A
_, err := st.AddObservation(AddObservationParams{
SessionID: "s1", Type: "bugfix", Title: "Fix auth token expiration",
Content: "The auth middleware was dropping tokens", Project: "project-a",
})
if err != nil { t.Fatal(err) }
_, err = st.AddObservation(AddObservationParams{
SessionID: "s1", Type: "bugfix", Title: "Auth token validation",
Content: "Middleware should validate auth tokens", Project: "project-a",
})
if err != nil { t.Fatal(err) }
_, err = st.AddObservation(AddObservationParams{
SessionID: "s1", Type: "bugfix", Title: "Minor fix",
Content: "Just a minor auth fix in the middleware", Project: "project-a",
})
if err != nil { t.Fatal(err) }

// Seed 1 highly relevant observation for project B
_, err = st.AddObservation(AddObservationParams{
SessionID: "s2", Type: "bugfix", Title: "Auth middleware completely rewritten",
Content: "Auth middleware auth middleware auth middleware tokens", Project: "project-b",
})
if err != nil { t.Fatal(err) }

// Seed an irrelevant observation for project C
_, err = st.AddObservation(AddObservationParams{
SessionID: "s3", Type: "feature", Title: "Database migration",
Content: "Added new tables", Project: "project-c",
})
if err != nil { t.Fatal(err) }

// Force FTS sync if async (test setup normally does this, but just in case)
// We'll just search directly.

matches, err := st.SearchProjects("auth middleware", "all", 10)
if err != nil {
t.Fatalf("SearchProjects failed: %v", err)
}

if len(matches) != 2 {
t.Fatalf("Expected 2 projects, got %d: %+v", len(matches), matches)
}

// project-a should have 3 matches. project-b should have 1 match.
// project-b has more occurrences of the terms, so its top_rank might be better (more negative).
// Let's assert on the project names and counts.
hasA := false
hasB := false
for _, m := range matches {
if m.Project == "project-a" {
hasA = true
if m.MatchCount != 3 {
t.Errorf("project-a: expected 3 matches, got %d", m.MatchCount)
}
}
if m.Project == "project-b" {
hasB = true
if m.MatchCount != 1 {
t.Errorf("project-b: expected 1 match, got %d", m.MatchCount)
}
}
}
if !hasA || !hasB {
t.Errorf("Missing expected projects in results: %+v", matches)
}
}
Comment on lines +8834 to +8906

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Cover SearchProjects error and edge paths.

This only tests normal all-mode grouping. Add deterministic coverage for empty input, any mode, deleted/blank-project exclusion, limit bounds, query failures, and TopRank ordering.

As per path instructions, **/*_test.go must cover happy paths, error paths, and edge cases, and behavior changes without tests should be blocked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/store_test.go` around lines 8834 - 8906, Expand
TestSearchProjects to cover empty queries, any-mode matching, exclusion of
deleted or blank-project observations, zero/limited result bounds, query-error
propagation, and deterministic TopRank ordering. Use the existing test store and
search-related helpers to create each scenario, assert expected results and
errors, and retain the current all-mode grouping assertions.

Source: Path instructions

Loading