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
112 changes: 112 additions & 0 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ var ProfileAgent = map[string]bool{
"mem_update": true, // update observation by ID — skills say "use mem_update when you have an exact ID to correct"
"mem_current_project": true, // detect current project — recommended first call for agents (REQ-313)
"mem_judge": true, // record verdict on a pending memory conflict (REQ-003, Phase D)
"mem_consolidate": true, // atomic merge N observations into 1 — garbage collection for agents
}

// ProfileAdmin contains tools for TUI, dashboards, and manual curation
Expand Down Expand Up @@ -363,6 +364,45 @@ Examples:
)
}

// ─── mem_consolidate (profile: agent) ────────────────────────────────
if shouldRegister("mem_consolidate", allowlist) {
srv.AddTool(
mcp.NewTool("mem_consolidate",
mcp.WithDescription(`Atomically merge multiple observations into one. Soft-deletes the source observations and creates a new consolidated observation in a single transaction. If the insert fails, all deletes are rolled back.

Use this when an agent has accumulated multiple related observations over time (e.g., iterations on the same architecture decision) and wants to clean up into a single, authoritative observation.`),
mcp.WithDeferLoading(true),
mcp.WithTitleAnnotation("Consolidate Memories"),
mcp.WithReadOnlyHintAnnotation(false),
mcp.WithDestructiveHintAnnotation(true),
mcp.WithIdempotentHintAnnotation(false),
mcp.WithOpenWorldHintAnnotation(false),
mcp.WithArray("ids_to_delete",
mcp.Required(),
mcp.Description("Array of observation IDs to soft-delete and consolidate"),
),
mcp.WithString("new_title",
mcp.Required(),
mcp.Description("Title for the new consolidated observation"),
),
mcp.WithString("new_content",
mcp.Required(),
mcp.Description("Content for the new consolidated observation"),
),
mcp.WithString("type",
mcp.Description("Category: decision, architecture, bugfix, pattern, config, discovery, learning (default: manual)"),
),
mcp.WithString("scope",
mcp.Description("Scope: project (default) or personal"),
),
mcp.WithString("topic_key",
mcp.Description("Optional topic key for the consolidated observation"),
),
),
handleConsolidate(s, cfg, activity),
)
}

// ─── mem_suggest_topic_key (profile: agent, deferred) ───────────────
if shouldRegister("mem_suggest_topic_key", allowlist) {
srv.AddTool(
Expand Down Expand Up @@ -1031,6 +1071,78 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
}
}

func handleConsolidate(s *store.Store, cfg MCPConfig, activity *SessionActivity) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
newTitle, _ := req.GetArguments()["new_title"].(string)
newContent, _ := req.GetArguments()["new_content"].(string)
typ, _ := req.GetArguments()["type"].(string)
scope, _ := req.GetArguments()["scope"].(string)
topicKey, _ := req.GetArguments()["topic_key"].(string)

// Parse ids_to_delete from JSON array
rawIDs, _ := req.GetArguments()["ids_to_delete"].([]interface{})
var idsToDelete []int64
for _, raw := range rawIDs {
switch v := raw.(type) {
case float64:
idsToDelete = append(idsToDelete, int64(v))
case json.Number:
n, _ := v.Int64()
idsToDelete = append(idsToDelete, n)
}
Comment on lines +1082 to +1092
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

The ids_to_delete parsing silently truncates float64 values and ignores json.Number conversion errors, which can lead to surprising behavior for non-integer inputs (e.g., 1.9 becomes 1) and makes it hard to debug bad requests. Since this tool is destructive, consider validating that every element is a positive integer and returning a clear error if any element is invalid/out of range, instead of best-effort parsing.

Copilot uses AI. Check for mistakes.
}

if len(idsToDelete) == 0 {
return mcp.NewToolResultError("ids_to_delete is required and must contain at least one ID"), nil
}
if newTitle == "" {
return mcp.NewToolResultError("new_title is required"), nil
}
if newContent == "" {
return mcp.NewToolResultError("new_content is required"), nil
}

// Auto-detect project (same pattern as handleSave)
detRes, err := resolveWriteProject()
if err != nil {
return errorWithMeta("ambiguous_project",
fmt.Sprintf("Cannot determine project: %s", err),
detRes.AvailableProjects,
), nil
}
project := detRes.Project
normalized, _ := store.NormalizeProject(project)
project = normalized

if typ == "" {
typ = "manual"
}
sessionID := defaultSessionID(project)

// Ensure the session exists
s.CreateSession(sessionID, project, "")

newID, err := s.Consolidate(idsToDelete, store.AddObservationParams{
SessionID: sessionID,
Type: typ,
Title: newTitle,
Content: newContent,
Project: project,
Scope: scope,
TopicKey: topicKey,
})
if err != nil {
return mcp.NewToolResultError("Failed to consolidate: " + err.Error()), nil
}

activity.RecordSave(defaultSessionID(project))

msg := fmt.Sprintf("Consolidated %d observations into new observation #%d: %q", len(idsToDelete), newID, newTitle)
detRes.Project = project
return respondWithProject(detRes, msg, nil), nil
}
}

func handleSuggestTopicKey() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
typ, _ := req.GetArguments()["type"].(string)
Expand Down
25 changes: 13 additions & 12 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ func TestResolveToolsAgentProfile(t *testing.T) {
"mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct"
"mem_current_project", // added REQ-313: discovery tool recommended first call
"mem_judge", // REQ-003: conflict verdict tool (Phase D)
"mem_consolidate", // atomic merge N observations into 1 — garbage collection for agents
}
for _, tool := range expectedTools {
if !result[tool] {
Expand Down Expand Up @@ -1006,13 +1007,13 @@ func TestResolveToolsCombinedProfiles(t *testing.T) {
t.Fatal("expected non-nil allowlist for combined profiles")
}

// Should have all 17 tools (16 prior + mem_judge added in Phase D)
// Should have all 18 tools (17 prior + mem_consolidate)
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_merge_projects",
"mem_current_project", "mem_judge",
"mem_current_project", "mem_judge", "mem_consolidate",
}
for _, tool := range allTools {
if !result[tool] {
Expand Down Expand Up @@ -1598,7 +1599,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) {
"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",
"mem_current_project", "mem_judge",
"mem_current_project", "mem_judge", "mem_consolidate",
}

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

// 13 agent + 4 admin = 17 total (mem_judge added in Phase D)
if len(tools) != 17 {
t.Errorf("NewServer should register all 17 tools, got %d", len(tools))
// 14 agent + 4 admin = 18 total (mem_consolidate added)
if len(tools) != 18 {
t.Errorf("NewServer should register all 18 tools, got %d", len(tools))
}
}

Expand All @@ -1653,9 +1654,9 @@ func TestProfileConsistency(t *testing.T) {
combined[tool] = true
}

// 13 agent + 4 admin = 17 total (mem_judge added in Phase D)
if len(combined) != 17 {
t.Errorf("agent + admin should cover all 17 tools, got %d", len(combined))
// 14 agent + 4 admin = 18 total (mem_consolidate added)
if len(combined) != 18 {
t.Errorf("agent + admin should cover all 18 tools, got %d", len(combined))
}

// Verify no overlap between profiles
Expand Down Expand Up @@ -1980,9 +1981,9 @@ func TestNewServerWithConfig(t *testing.T) {
t.Fatal("expected MCP server instance")
}
tools := srv.ListTools()
// Should have all 17 tools (13 agent + 4 admin; mem_judge added in Phase D)
if len(tools) != 17 {
t.Errorf("NewServerWithConfig should register all 17 tools, got %d", len(tools))
// Should have all 18 tools (14 agent + 4 admin; mem_consolidate added)
if len(tools) != 18 {
t.Errorf("NewServerWithConfig should register all 18 tools, got %d", len(tools))
}
}

Expand Down
78 changes: 78 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,84 @@ func (s *Store) DeleteObservation(id int64, hardDelete bool) error {
})
}

// Consolidate atomically soft-deletes source observations and creates a new
// consolidated observation in a single transaction. If the INSERT fails, all
// soft-deletes are rolled back. Non-existent IDs are silently ignored.
func (s *Store) Consolidate(idsToDelete []int64, p AddObservationParams) (int64, error) {
p.Project, _ = NormalizeProject(p.Project)
title := stripPrivateTags(p.Title)
content := stripPrivateTags(p.Content)
if len(content) > s.cfg.MaxObservationLength {
content = content[:s.cfg.MaxObservationLength] + "... [truncated]"
}
scope := normalizeScope(p.Scope)
normHash := hashNormalized(content)
topicKey := normalizeTopicKey(p.TopicKey)

var observationID int64
err := s.withTx(func(tx *sql.Tx) error {
// Phase 1: Soft-delete sources (ignore non-existent)
for _, id := range idsToDelete {
obs, err := s.getObservationTx(tx, id)
if err == sql.ErrNoRows {
continue // silently ignore non-existent or already-deleted
}
if err != nil {
Comment on lines +2468 to +2474
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

idsToDelete is processed as-is. If the caller passes duplicate IDs, this will enqueue multiple delete sync mutations for the same observation (second UPDATE will be a no-op due to deleted_at IS NULL, but the mutation still gets enqueued). Consider de-duplicating idsToDelete (and optionally filtering out <=0 IDs) before the loop to keep sync mutations consistent and avoid unnecessary work.

Copilot uses AI. Check for mistakes.
return err
}

deletedAt := Now()
if _, err := s.execHook(tx,
`UPDATE observations
SET deleted_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ? AND deleted_at IS NULL`,
id,
); err != nil {
return err
}
if err := tx.QueryRow(`SELECT deleted_at FROM observations WHERE id = ?`, id).Scan(&deletedAt); err != nil {
return err
}
if err := s.enqueueSyncMutationTx(tx, SyncEntityObservation, obs.SyncID, SyncOpDelete, syncObservationPayload{
SyncID: obs.SyncID,
SessionID: obs.SessionID,
Project: obs.Project,
Deleted: true,
DeletedAt: &deletedAt,
}); err != nil {
return err
}
}

// Phase 2: Insert new consolidated observation
syncID := newSyncID("obs")
res, err := s.execHook(tx,
`INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, datetime('now'), datetime('now'))`,
syncID, p.SessionID, p.Type, title, content,
nullableString(p.ToolName), nullableString(p.Project), scope, nullableString(topicKey), normHash,
)
if err != nil {
return err
}
observationID, err = res.LastInsertId()
if err != nil {
return err
}
obs, err := s.getObservationTx(tx, observationID)
if err != nil {
return err
}
return s.enqueueSyncMutationTx(tx, SyncEntityObservation, obs.SyncID, SyncOpUpsert, observationPayloadFromObservation(obs))
})
if err != nil {
return 0, err
}
return observationID, nil
}


// ─── Timeline ────────────────────────────────────────────────────────────────
//
// Timeline provides chronological context around a specific observation.
Expand Down
Loading