Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions cmd/lifecycle-test/phase_longterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
156 changes: 108 additions & 48 deletions internal/agent/encoding/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions internal/api/routes/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 116 additions & 5 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
}
5 changes: 3 additions & 2 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading