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
70 changes: 70 additions & 0 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"os"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -833,7 +834,33 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv
var b strings.Builder
fmt.Fprintf(&b, "Found %d memories:\n\n", len(results))
anyTruncated := false

// Track seen IDs and group topic_keys by (project, scope) so the
// related-by-topic lookup respects the same project/scope partition
// the search ran in.
type psKey struct {
project string
scope string
}
seenIDs := make(map[int64]bool)
excludeByPS := make(map[psKey][]int64)
keysByPS := make(map[psKey]map[string]bool)

for i, r := range results {
seenIDs[r.ID] = true
if r.TopicKey != nil && *r.TopicKey != "" {
resultProject := ""
if r.Project != nil {
resultProject = *r.Project
}
k := psKey{project: resultProject, scope: r.Scope}
if keysByPS[k] == nil {
keysByPS[k] = make(map[string]bool)
}
keysByPS[k][*r.TopicKey] = true
excludeByPS[k] = append(excludeByPS[k], r.ID)
}

projectDisplay := ""
if r.Project != nil {
projectDisplay = fmt.Sprintf(" | project: %s", *r.Project)
Expand Down Expand Up @@ -873,6 +900,49 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv
fmt.Fprintf(&b, "---\nResults above are previews (300 chars). To read the full content of a specific memory, call mem_get_observation(id: <ID>).\n")
}

// Implicit graph-linking: append topic_key siblings, grouped by topic.
if len(keysByPS) > 0 {
grouped := make(map[string][]store.RelatedObservation)
for ps, keysSet := range keysByPS {
keys := make([]string, 0, len(keysSet))
for k := range keysSet {
keys = append(keys, k)
}
sort.Strings(keys)

rel, err := s.RelatedByTopicKeys(keys, ps.project, ps.scope, excludeByPS[ps])
if err != nil || len(rel) == 0 {
continue
}
for tk, sibs := range rel {
for _, sib := range sibs {
if seenIDs[sib.ID] {
continue
}
grouped[tk] = append(grouped[tk], sib)
}
}
}

if len(grouped) > 0 {
topics := make([]string, 0, len(grouped))
for tk := range grouped {
topics = append(topics, tk)
}
sort.Strings(topics)

b.WriteString("\n---\nRelated Context (from graph):\n")
for _, tk := range topics {
sibs := grouped[tk]
parts := make([]string, 0, len(sibs))
for _, sib := range sibs {
parts = append(parts, fmt.Sprintf("ID %d (%s)", sib.ID, sib.Title))
}
fmt.Fprintf(&b, "Related (topic %q): %s\n", tk, strings.Join(parts, ", "))
}
}
}

if nudge := activity.NudgeIfNeeded(sessionID); nudge != "" {
b.WriteString(nudge)
}
Expand Down
75 changes: 75 additions & 0 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3900,3 +3900,78 @@ func TestHandleSave_MCPConfig_OverridesDefaults(t *testing.T) {
}
}
}

func TestSearchIncludesRelatedMetadata(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)

s := newMCPTestStore(t)
project := "graph-link-project"

if err := s.CreateSession("s1", project, "/tmp/proj"); err != nil {
t.Fatalf("create session: %v", err)
}

// Use the direct-insert test helper to bypass AddObservation's topic_key
// UPSERT path — two observations sharing topic_key + project + scope
// would otherwise collapse into one row.
if _, err := s.InsertObservationForTest("sync-primary", "s1", "bugfix", "Primary", "Primary content here", project, "project", "auth/jwt", "h-primary"); err != nil {
t.Fatalf("insert primary: %v", err)
}
// Sibling has distinct lexicon so it does NOT match the FTS query — it
// must arrive only via the implicit-graph topic lookup.
if _, err := s.InsertObservationForTest("sync-sibling", "s1", "decision", "Sibling Note", "unrelated lexical content", project, "project", "auth/jwt", "h-sibling"); err != nil {
t.Fatalf("insert sibling: %v", err)
}

search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
"query": "Primary",
"project": project,
}}}
res, err := search(context.Background(), req)
if err != nil || res.IsError {
t.Fatalf("search err: %v isError: %v", err, res.IsError)
}

output := callResultText(t, res)
if !strings.Contains(output, "Related Context (from graph):") {
t.Fatalf("expected related context section, got:\n%s", output)
}
if !strings.Contains(output, `Related (topic \"auth/jwt\"):`) {
t.Fatalf("expected per-topic header, got:\n%s", output)
}
if !strings.Contains(output, "Sibling Note") {
t.Fatalf("expected related observation title in output, got:\n%s", output)
}
}

func TestSearchNoRelatedWhenNoTopicKeys(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)

s := newMCPTestStore(t)
project := "no-topic-project"

if err := s.CreateSession("s1", project, "/tmp/proj"); err != nil {
t.Fatalf("create session: %v", err)
}
if _, err := s.AddObservation(store.AddObservationParams{SessionID: "s1", Type: "bugfix", Title: "Standalone issue", Content: "Standalone content", Project: project, Scope: "project"}); err != nil {
t.Fatalf("err: %v", err)
}

search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
"query": "Standalone",
"project": project,
}}}
res, err := search(context.Background(), req)
if err != nil || res.IsError {
t.Fatalf("search err: %v isError: %v", err, res.IsError)
}

output := callResultText(t, res)
if strings.Contains(output, "Related Context (from graph):") {
t.Fatalf("did not expect related context section, got:\n%s", output)
}
}
79 changes: 79 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5700,3 +5700,82 @@ func (s *Store) ListObservationSyncPayloads() ([]any, error) {
}
return payloads, nil
}

// RelatedObservation is a sibling observation surfaced by RelatedByTopicKeys.
type RelatedObservation struct {
ID int64
Title string
}

// InsertObservationForTest inserts an observation row without going through
// AddObservation's topic_key UPSERT path. Intended ONLY for tests that need
// to seed multiple sibling rows sharing (topic_key, project, scope).
func (s *Store) InsertObservationForTest(syncID, sessionID, obsType, title, content, project, scope, topicKey, normalizedHash string) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO observations (sync_id, session_id, type, title, content, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, datetime('now'), datetime('now'))
`, syncID, sessionID, obsType, title, content, project, scope, topicKey, normalizedHash)
if err != nil {
return 0, err
}
return res.LastInsertId()
}

// RelatedByTopicKeys retrieves up to 5 sibling observations per topic_key for
// implicit graph-linking. Filters by project + scope and excludes the given IDs.
func (s *Store) RelatedByTopicKeys(keys []string, project, scope string, excludeIDs []int64) (map[string][]RelatedObservation, error) {
if len(keys) == 0 {
return make(map[string][]RelatedObservation), nil
}

keysPlaceholders := make([]string, len(keys))
args := make([]interface{}, 0, len(keys)+2+len(excludeIDs))
for i, k := range keys {
keysPlaceholders[i] = "?"
args = append(args, k)
}
args = append(args, project, scope)

excludeClause := ""
if len(excludeIDs) > 0 {
excludePlaceholders := make([]string, len(excludeIDs))
for i, id := range excludeIDs {
excludePlaceholders[i] = "?"
args = append(args, id)
}
excludeClause = " AND id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")"
}

query := fmt.Sprintf(`
SELECT id, title, topic_key
FROM observations
WHERE topic_key IN (%s)
AND deleted_at IS NULL
AND project = ?
AND scope = ?
%s
ORDER BY updated_at DESC
`, strings.Join(keysPlaceholders, ","), excludeClause)
Comment on lines +5749 to +5758
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

RelatedByTopicKeys currently selects all rows matching the topic_key set and only enforces the “max 5 siblings per topic_key” limit in Go. If a topic_key has many observations, this can scan and allocate far more rows than needed and slow down mem_search. Consider enforcing the per-topic limit in SQL (e.g., via a window function ROW_NUMBER() OVER (PARTITION BY topic_key ORDER BY updated_at DESC) and filtering rn<=5) so SQLite can stop earlier and do less work.

Copilot uses AI. Check for mistakes.

rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("related by topic keys query: %w", err)
}
defer rows.Close()

result := make(map[string][]RelatedObservation)
for rows.Next() {
var obs RelatedObservation
var topicKey string
if err := rows.Scan(&obs.ID, &obs.Title, &topicKey); err != nil {
return nil, fmt.Errorf("scan related observation: %w", err)
}
if len(result[topicKey]) < 5 {
result[topicKey] = append(result[topicKey], obs)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration: %w", err)
}
return result, nil
}
Loading
Loading