diff --git a/CLAUDE.md b/CLAUDE.md index e76d9442..53949f70 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 (22 tools for Claude Code) + mcp/ MCP server (23 tools for Claude Code) store/ Store interface + SQLite implementation llm/ LLM provider interface + implementations (LM Studio, Gemini/cloud API) ingest/ Project ingestion engine @@ -137,6 +137,7 @@ You have 21 tools via the `mnemonic` MCP server: | `exclude_path` | Add a watcher exclusion pattern at runtime | | `list_exclusions` | List all runtime watcher exclusion patterns | | `dismiss_pattern` | Archive a stale or irrelevant pattern to stop it surfacing in recall | +| `create_handoff` | Store structured session handoff notes (high salience, surfaced by recall_project) | ### At Session Start diff --git a/cmd/lifecycle-test/phase_longterm.go b/cmd/lifecycle-test/phase_longterm.go index fa0a9394..4857e859 100644 --- a/cmd/lifecycle-test/phase_longterm.go +++ b/cmd/lifecycle-test/phase_longterm.go @@ -81,8 +81,8 @@ func (p *PhaseLongterm) Run(ctx context.Context, h *Harness, verbose bool) (*Pha totalResults := 0 for _, q := range regressionQueries { qr, err := h.Retriever.Query(ctx, retrieval.QueryRequest{ - Query: q, - MaxResults: 5, + Query: q, + MaxResults: 5, IncludeSuppressed: true, }) if err == nil { 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/api/routes/analytics.go b/internal/api/routes/analytics.go index c7738993..1e705b23 100644 --- a/internal/api/routes/analytics.go +++ b/internal/api/routes/analytics.go @@ -13,14 +13,14 @@ import ( // AnalyticsResponse is the JSON response for the research analytics endpoint. type AnalyticsResponse struct { - Pipeline PipelineMetrics `json:"pipeline"` + Pipeline PipelineMetrics `json:"pipeline"` SignalNoise map[string]store.SignalNoiseEntry `json:"signal_noise"` - RecallEffectiveness []store.RecallBucket `json:"recall_effectiveness"` - FeedbackTrend []store.FeedbackTrendEntry `json:"feedback_trend"` - ConsolidationHistory []store.ConsolidationEntry `json:"consolidation_history"` - MemorySurvival []store.SurvivalEntry `json:"memory_survival"` - SalienceDistribution map[string]map[string]int `json:"salience_distribution"` - Timestamp string `json:"timestamp"` + RecallEffectiveness []store.RecallBucket `json:"recall_effectiveness"` + FeedbackTrend []store.FeedbackTrendEntry `json:"feedback_trend"` + ConsolidationHistory []store.ConsolidationEntry `json:"consolidation_history"` + MemorySurvival []store.SurvivalEntry `json:"memory_survival"` + SalienceDistribution map[string]map[string]int `json:"salience_distribution"` + Timestamp string `json:"timestamp"` } // PipelineMetrics shows encoding efficiency. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2350f2b1..7eb47aff 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -285,6 +285,8 @@ func (srv *MCPServer) handleToolCall(ctx context.Context, req *jsonRPCRequest) * result, toolErr = srv.handleCheckMemory(ctx, params.Arguments) case "dismiss_pattern": result, toolErr = srv.handleDismissPattern(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)) } @@ -405,6 +407,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, @@ -417,11 +445,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 @@ -2560,3 +2584,90 @@ func (srv *MCPServer) handleDismissPattern(_ context.Context, args map[string]in srv.log.Info("pattern dismissed", "pattern_id", patternID, "session_id", srv.sessionID) return toolResult(fmt.Sprintf("Pattern %s archived", patternID)), 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 b5f73b8d..06c0af1e 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) != 22 { - t.Fatalf("expected 22 tools, got %d", len(toolsArray)) + if len(toolsArray) != 23 { + t.Fatalf("expected 23 tools, got %d", len(toolsArray)) } // Verify tool names @@ -155,6 +155,7 @@ func TestHandleToolsList(t *testing.T) { "exclude_path": false, "list_exclusions": false, "dismiss_pattern": false, + "create_handoff": false, } for _, toolInterface := range toolsArray { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 0042c51c..4a65c1fa 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"}, }, @@ -576,6 +595,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{ @@ -601,5 +656,6 @@ func allToolDefs() []ToolDefinition { amendToolDef(), checkMemoryToolDef(), dismissPatternToolDef(), + createHandoffToolDef(), } }