From ff7ff794f6dee1b91ccdfdda76cc6b0dd6d7e3fd Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Sat, 21 Mar 2026 01:36:44 -0400 Subject: [PATCH 1/2] feat: explicit associations on remember and create_handoff tool - remember tool now accepts associate_with param to create explicit associations at write time. Targets are stored in raw metadata and created by the encoding agent alongside auto-discovered associations (strength 0.9, user-specified relation type). - New create_handoff MCP tool (22nd tool) stores structured session handoff notes with fields: completed, pending, to_test, known_issues, next_session_hint. Stored as type "handoff" with 0.95 salience. - Both encoding paths (LLM and fallback) handle explicit associations. - Updated CLAUDE.md tool count and table. Closes #308 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 +- internal/agent/encoding/agent.go | 156 +++++++++++++++++++++---------- internal/mcp/server.go | 121 +++++++++++++++++++++++- internal/mcp/server_test.go | 5 +- internal/mcp/tools.go | 56 +++++++++++ 5 files changed, 285 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70aa23db..3c2ea269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ internal/ reactor/ Event-driven rule engine api/ REST API server + routes web/ Embedded dashboard (single-page app, D3.js charts) - mcp/ MCP server (21 tools for Claude Code) + mcp/ MCP server (22 tools for Claude Code) store/ Store interface + SQLite implementation llm/ LLM provider interface + implementations (LM Studio, Gemini/cloud API) ingest/ Project ingestion engine @@ -136,6 +136,7 @@ You have 21 tools via the `mnemonic` MCP server: | `ingest_project` | Bulk-ingest a project directory into memory | | `exclude_path` | Add a watcher exclusion pattern at runtime | | `list_exclusions` | List all runtime watcher exclusion patterns | +| `create_handoff` | Store structured session handoff notes (high salience, surfaced by recall_project) | ### At Session Start diff --git a/internal/agent/encoding/agent.go b/internal/agent/encoding/agent.go index f85d87fb..d8f32a33 100644 --- a/internal/agent/encoding/agent.go +++ b/internal/agent/encoding/agent.go @@ -51,32 +51,32 @@ type EncodingAgent struct { // EncodingConfig holds configurable parameters for the encoding agent. type EncodingConfig struct { - PollingInterval time.Duration - SimilarityThreshold float32 - MaxSimilarSearchResults int - EmbeddingModel string - CompletionModel string - CompletionMaxTokens int - CompletionTemperature float32 - MaxConcurrentEncodings int // max concurrent LLM encoding calls (default 1 for local models) - EnableLLMClassification bool // if true, use LLM to reclassify "similar" associations in background - CoachingFile string // path to coaching.yaml; empty = no coaching - ExcludePatterns []string // paths matching these patterns are skipped (defense-in-depth) - ConceptVocabulary []string // controlled vocabulary for concept extraction; empty = free-form - MaxRetries int // encoding attempts before skipping (default: 3) - MaxLLMContentChars int // max chars sent to LLM for compression (default: 8000) - MaxEmbeddingChars int // max chars sent to embedding model (default: 4000) - TemporalWindowMin int // minutes for temporal relationship detection (default: 5) - BackoffThreshold int // consecutive failures before backoff (default: 3) - BackoffBaseSec int // base backoff per failure in seconds (default: 30) - BackoffMaxSec int // maximum backoff in seconds (default: 300) - BatchSizeEvent int // batch size for EncodeAllPending (default: 50) - BatchSizePoll int // batch size for polling loop (default: 10) - EmbedBatchSize int // max memories to batch-embed in one call (default 10) - DeduplicationThreshold float32 // cosine sim above which new memory is a duplicate (default: 0.95) - MCPDeduplicationThreshold float32 // higher threshold for MCP-sourced memories (default: 0.98) - SalienceFloor float32 // min salience to encode; non-MCP sources below this are skipped (default: 0.5) - DisablePolling bool // if true, skip the polling loop (MCP processes should not poll) + PollingInterval time.Duration + SimilarityThreshold float32 + MaxSimilarSearchResults int + EmbeddingModel string + CompletionModel string + CompletionMaxTokens int + CompletionTemperature float32 + MaxConcurrentEncodings int // max concurrent LLM encoding calls (default 1 for local models) + EnableLLMClassification bool // if true, use LLM to reclassify "similar" associations in background + CoachingFile string // path to coaching.yaml; empty = no coaching + ExcludePatterns []string // paths matching these patterns are skipped (defense-in-depth) + ConceptVocabulary []string // controlled vocabulary for concept extraction; empty = free-form + MaxRetries int // encoding attempts before skipping (default: 3) + MaxLLMContentChars int // max chars sent to LLM for compression (default: 8000) + MaxEmbeddingChars int // max chars sent to embedding model (default: 4000) + TemporalWindowMin int // minutes for temporal relationship detection (default: 5) + BackoffThreshold int // consecutive failures before backoff (default: 3) + BackoffBaseSec int // base backoff per failure in seconds (default: 30) + BackoffMaxSec int // maximum backoff in seconds (default: 300) + BatchSizeEvent int // batch size for EncodeAllPending (default: 50) + BatchSizePoll int // batch size for polling loop (default: 10) + EmbedBatchSize int // max memories to batch-embed in one call (default 10) + DeduplicationThreshold float32 // cosine sim above which new memory is a duplicate (default: 0.95) + MCPDeduplicationThreshold float32 // higher threshold for MCP-sourced memories (default: 0.98) + SalienceFloor float32 // min salience to encode; non-MCP sources below this are skipped (default: 0.5) + DisablePolling bool // if true, skip the polling loop (MCP processes should not poll) } // compressedMemory holds the intermediate state between compression and embedding. @@ -109,28 +109,28 @@ var DefaultConceptVocabulary = []string{ // DefaultConfig returns sensible defaults for encoding configuration. func DefaultConfig() EncodingConfig { return EncodingConfig{ - PollingInterval: 5 * time.Second, - SimilarityThreshold: 0.3, - MaxSimilarSearchResults: 5, - EmbeddingModel: "default", - CompletionModel: "default", - CompletionMaxTokens: 1024, - CompletionTemperature: 0.3, - MaxConcurrentEncodings: 1, - EnableLLMClassification: false, - ConceptVocabulary: DefaultConceptVocabulary, - MaxRetries: 3, - MaxLLMContentChars: 8000, - MaxEmbeddingChars: 4000, - TemporalWindowMin: 5, - BackoffThreshold: 3, - BackoffBaseSec: 30, - BackoffMaxSec: 300, - BatchSizeEvent: 50, - BatchSizePoll: 10, - DeduplicationThreshold: 0.95, - MCPDeduplicationThreshold: 0.98, - SalienceFloor: 0.5, + PollingInterval: 5 * time.Second, + SimilarityThreshold: 0.3, + MaxSimilarSearchResults: 5, + EmbeddingModel: "default", + CompletionModel: "default", + CompletionMaxTokens: 1024, + CompletionTemperature: 0.3, + MaxConcurrentEncodings: 1, + EnableLLMClassification: false, + ConceptVocabulary: DefaultConceptVocabulary, + MaxRetries: 3, + MaxLLMContentChars: 8000, + MaxEmbeddingChars: 4000, + TemporalWindowMin: 5, + BackoffThreshold: 3, + BackoffBaseSec: 30, + BackoffMaxSec: 300, + BatchSizeEvent: 50, + BatchSizePoll: 10, + DeduplicationThreshold: 0.95, + MCPDeduplicationThreshold: 0.98, + SalienceFloor: 0.5, } } @@ -856,6 +856,36 @@ func (ea *EncodingAgent) finalizeEncodedMemory(ctx context.Context, raw store.Ra } } + // Create explicit associations from metadata (set via MCP remember associate_with param). + if rawAssoc, ok := raw.Metadata["explicit_associations"]; ok { + if assocList, ok := rawAssoc.([]interface{}); ok { + for _, entry := range assocList { + if m, ok := entry.(map[string]interface{}); ok { + targetID, _ := m["memory_id"].(string) + relation, _ := m["relation"].(string) + if targetID == "" || relation == "" { + continue + } + assoc := store.Association{ + SourceID: memoryID, + TargetID: targetID, + Strength: 0.9, + RelationType: relation, + CreatedAt: time.Now(), + LastActivated: time.Now(), + ActivationCount: 1, + } + if err := ea.store.CreateAssociation(ctx, assoc); err != nil { + ea.log.Warn("failed to create explicit association", + "source_id", memoryID, "target_id", targetID, "error", err) + } else { + associationsCreated++ + } + } + } + } + } + // Raw was already claimed (processed=1) by pollAndProcessRawMemories before // compression started. No additional MarkRawProcessed needed. @@ -1165,6 +1195,36 @@ func (ea *EncodingAgent) encodeMemory(ctx context.Context, rawID string) error { } } + // Create explicit associations from metadata (set via MCP remember associate_with param). + if rawAssoc, ok := raw.Metadata["explicit_associations"]; ok { + if assocList, ok := rawAssoc.([]interface{}); ok { + for _, entry := range assocList { + if m, ok := entry.(map[string]interface{}); ok { + targetID, _ := m["memory_id"].(string) + relation, _ := m["relation"].(string) + if targetID == "" || relation == "" { + continue + } + assoc := store.Association{ + SourceID: memoryID, + TargetID: targetID, + Strength: 0.9, + RelationType: relation, + CreatedAt: time.Now(), + LastActivated: time.Now(), + ActivationCount: 1, + } + if err := ea.store.CreateAssociation(ctx, assoc); err != nil { + ea.log.Warn("failed to create explicit association", + "source_id", memoryID, "target_id", targetID, "error", err) + } else { + associationsCreated++ + } + } + } + } + } + // Step 7: Raw was already claimed (processed=1) in Step 0. No additional mark needed. // Step 8: Publish MemoryEncoded event diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 91a8068c..af1d5f3e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -283,6 +283,8 @@ func (srv *MCPServer) handleToolCall(ctx context.Context, req *jsonRPCRequest) * result, toolErr = srv.handleAmend(ctx, params.Arguments) case "check_memory": result, toolErr = srv.handleCheckMemory(ctx, params.Arguments) + case "create_handoff": + result, toolErr = srv.handleCreateHandoff(ctx, params.Arguments) default: return errorResponse(req.ID, -32602, fmt.Sprintf("Unknown tool: %s", params.Name)) } @@ -403,6 +405,32 @@ func (srv *MCPServer) handleRemember(ctx context.Context, args map[string]interf project = srv.resolveProjectName(p) } + // Parse optional explicit associations. + var explicitAssoc []map[string]string + if rawAssoc, ok := args["associate_with"].([]interface{}); ok { + for _, entry := range rawAssoc { + if m, ok := entry.(map[string]interface{}); ok { + memID, _ := m["memory_id"].(string) + relation, _ := m["relation"].(string) + if memID != "" && relation != "" { + explicitAssoc = append(explicitAssoc, map[string]string{ + "memory_id": memID, + "relation": relation, + }) + } + } + } + } + + metadata := map[string]interface{}{ + "mcp_session_id": srv.sessionID, + "memory_type": memType, + "project": project, + } + if len(explicitAssoc) > 0 { + metadata["explicit_associations"] = explicitAssoc + } + raw := store.RawMemory{ ID: uuid.New().String(), Source: source, @@ -415,11 +443,7 @@ func (srv *MCPServer) handleRemember(ctx context.Context, args map[string]interf Processed: false, Project: project, SessionID: srv.sessionID, - Metadata: map[string]interface{}{ - "mcp_session_id": srv.sessionID, - "memory_type": memType, - "project": project, - }, + Metadata: metadata, } // Boost salience for specific types @@ -2480,3 +2504,90 @@ func (srv *MCPServer) handleCheckMemory(ctx context.Context, args map[string]int return toolResult(sb.String()), nil } + +// handleCreateHandoff stores a structured session handoff note as a high-salience memory. +func (srv *MCPServer) handleCreateHandoff(ctx context.Context, args map[string]interface{}) (interface{}, error) { + // Parse all fields. + var completed, pending, toTest, knownIssues []string + for _, pair := range []struct { + key string + dest *[]string + }{ + {"completed", &completed}, + {"pending", &pending}, + {"to_test", &toTest}, + {"known_issues", &knownIssues}, + } { + if raw, ok := args[pair.key].([]interface{}); ok { + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + *pair.dest = append(*pair.dest, s) + } + } + } + } + nextHint, _ := args["next_session_hint"].(string) + + if len(completed) == 0 && len(pending) == 0 && len(toTest) == 0 && len(knownIssues) == 0 && nextHint == "" { + return nil, fmt.Errorf("at least one field must be provided") + } + + // Format as readable text. + var sb strings.Builder + fmt.Fprintf(&sb, "SESSION HANDOFF — %s — %s\n\n", srv.project, time.Now().Format("2006-01-02 15:04")) + writeSection := func(title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(&sb, "%s:\n", title) + for _, item := range items { + fmt.Fprintf(&sb, "- %s\n", item) + } + sb.WriteString("\n") + } + writeSection("Completed", completed) + writeSection("Pending", pending) + writeSection("To Test", toTest) + writeSection("Known Issues", knownIssues) + if nextHint != "" { + fmt.Fprintf(&sb, "Next session: %s\n", nextHint) + } + + raw := store.RawMemory{ + ID: uuid.New().String(), + Source: "mcp", + Type: "handoff", + Content: sb.String(), + Timestamp: time.Now(), + CreatedAt: time.Now(), + HeuristicScore: 0.9, + InitialSalience: 0.95, + Processed: false, + Project: srv.project, + SessionID: srv.sessionID, + Metadata: map[string]interface{}{ + "mcp_session_id": srv.sessionID, + "memory_type": "handoff", + "project": srv.project, + "completed": completed, + "pending": pending, + "to_test": toTest, + "known_issues": knownIssues, + "next_session_hint": nextHint, + }, + } + + if err := srv.store.WriteRaw(ctx, raw); err != nil { + return nil, fmt.Errorf("failed to store handoff: %w", err) + } + if srv.bus != nil { + _ = srv.bus.Publish(ctx, events.RawMemoryCreated{ + ID: raw.ID, + Source: raw.Source, + Ts: time.Now(), + }) + } + + srv.log.Info("session handoff created", "id", raw.ID, "project", srv.project) + return toolResult(fmt.Sprintf("Handoff stored (id: %s, salience: 0.95)\nWill be surfaced by recall_project in the next session.", raw.ID)), nil +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 1582a563..0750f252 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -127,8 +127,8 @@ func TestHandleToolsList(t *testing.T) { t.Fatalf("tools is not an array, got %T", toolsInterface) } - if len(toolsArray) != 21 { - t.Fatalf("expected 21 tools, got %d", len(toolsArray)) + if len(toolsArray) != 22 { + t.Fatalf("expected 22 tools, got %d", len(toolsArray)) } // Verify tool names @@ -154,6 +154,7 @@ func TestHandleToolsList(t *testing.T) { "check_memory": false, "exclude_path": false, "list_exclusions": false, + "create_handoff": false, } for _, toolInterface := range toolsArray { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 914a6487..ba9c8a17 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -27,6 +27,25 @@ func rememberToolDef() ToolDefinition { "type": "string", "description": "Project name (auto-detected from working directory if omitted)", }, + "associate_with": map[string]interface{}{ + "type": "array", + "description": "Create explicit associations with existing memories at write time", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "memory_id": map[string]interface{}{ + "type": "string", + "description": "ID of the memory to associate with", + }, + "relation": map[string]interface{}{ + "type": "string", + "description": "Relation type", + "enum": []string{"similar", "caused_by", "part_of", "contradicts", "temporal", "reinforces"}, + }, + }, + "required": []string{"memory_id", "relation"}, + }, + }, }, "required": []string{"text"}, }, @@ -550,6 +569,42 @@ func checkMemoryToolDef() ToolDefinition { } } +func createHandoffToolDef() ToolDefinition { + return ToolDefinition{ + Name: "create_handoff", + Description: "Create a structured session handoff note for the next session. Stored with high salience and automatically surfaced by recall_project. Use at session end to preserve continuity.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "completed": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Tasks completed this session", + }, + "pending": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Tasks started but not finished", + }, + "to_test": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Items that need testing", + }, + "known_issues": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Known bugs or issues discovered", + }, + "next_session_hint": map[string]interface{}{ + "type": "string", + "description": "Suggested starting point for the next session", + }, + }, + }, + } +} + // allToolDefs returns the complete list of MCP tool definitions. func allToolDefs() []ToolDefinition { return []ToolDefinition{ @@ -574,5 +629,6 @@ func allToolDefs() []ToolDefinition { listExclusionsToolDef(), amendToolDef(), checkMemoryToolDef(), + createHandoffToolDef(), } } From c115e60f4898fe7c175a399dff9fb26a4ae9c7a0 Mon Sep 17 00:00:00 2001 From: Caleb Gross Date: Sat, 21 Mar 2026 01:44:12 -0400 Subject: [PATCH 2/2] chore: remove accidentally staged worktree dirs Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/worktrees/agent-affb08fa | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/agent-affb08fa diff --git a/.claude/worktrees/agent-affb08fa b/.claude/worktrees/agent-affb08fa deleted file mode 160000 index 11d3c44d..00000000 --- a/.claude/worktrees/agent-affb08fa +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 11d3c44d7d56a53b8019e3233601a2abd8de6cc0