// Package synap provides a production-ready Go client for the Synap cognitive memory system. // // Synap is a biologically-inspired memory database with spreading activation, episodic memory, // and automatic consolidation. This client is designed for use in chat systems to provide // context-aware AI responses with memory continuity. // // Usage Example: // // client, err := synap.NewClient("http://localhost:7432", &synap.Config{ // Timeout: 10 * time.Second, // MaxRetries: 3, // DefaultSpace: "conversation_abc123", // }) // if err != nil { // log.Fatal(err) // } // // // Store a chat message as episodic memory // episode := &synap.Episode{ // What: "User asked about their favorite hobbies", // When: time.Now(), // Who: []string{"user_alice", "agent_sarah"}, // Where: "chat", // Confidence: 0.85, // } // memoryID, err := client.RememberEpisode(ctx, episode) // // // Recall relevant memories for context // memories, err := client.Recall(ctx, &synap.RecallRequest{ // Query: "hobbies photography travel", // Mode: synap.RecallModeHybrid, // MaxResults: 10, // Threshold: 0.5, // }) package synap import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "git.threesix.ai/jordan/persona-community-1/pkg/httpclient" ) // Client provides access to the Synap memory system. type Client struct { baseURL string httpClient *httpclient.Client logger *slog.Logger config *Config } // Config holds configuration for the Synap client. type Config struct { // Timeout for HTTP requests (default: 10s) Timeout time.Duration // MaxRetries for failed requests (default: 3) MaxRetries int // DefaultSpace for multi-tenant isolation (optional) // If set, all requests will use this space unless overridden DefaultSpace string // APIKey for authentication (optional) // If set, requests will include "Authorization: Bearer " header APIKey string // Logger for structured logging (optional) Logger *slog.Logger } // NewClient creates a new Synap client. // // baseURL should be the Synap server URL (e.g., "http://localhost:7432") // config is optional - pass nil to use defaults // // Returns nil and an error if baseURL is invalid. func NewClient(baseURL string, config *Config) (*Client, error) { if baseURL == "" { return nil, fmt.Errorf("baseURL is required") } // Validate baseURL format parsedURL, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("invalid baseURL: %w", err) } if parsedURL.Scheme == "" { return nil, fmt.Errorf("baseURL must include scheme (http:// or https://)") } if parsedURL.Host == "" { return nil, fmt.Errorf("baseURL must include host") } if config == nil { config = &Config{} } if config.Timeout == 0 { config.Timeout = 10 * time.Second } if config.MaxRetries == 0 { config.MaxRetries = 3 } if config.Logger == nil { config.Logger = slog.Default() } return &Client{ baseURL: baseURL, httpClient: httpclient.New(httpclient.Config{ Timeout: config.Timeout, MaxRetries: config.MaxRetries, Logger: config.Logger, }), logger: config.Logger, config: config, }, nil } // Episode represents an episodic memory (what/when/where/who/why/how). // // Episodic memories are rich contextual memories that capture events with // multiple dimensions. They consolidate over time into semantic patterns. type Episode struct { // What happened (required) What string `json:"what"` // When it happened (required for temporal context) When time.Time `json:"when"` // Where it happened (optional, e.g., "chat", "email", "phone") Where string `json:"where,omitempty"` // Who was involved (optional, e.g., ["user_alice", "agent_sarah"]) Who []string `json:"who,omitempty"` // Why it happened (optional, context/motivation) Why string `json:"why,omitempty"` // How it happened (optional, method/process) How string `json:"how,omitempty"` // Confidence in memory accuracy (0.0-1.0, default: 0.7) // Higher confidence = stronger memory encoding Confidence float64 `json:"confidence"` // Tags for categorization (optional) Tags []string `json:"tags,omitempty"` } // RememberEpisodeResponse contains the result of storing an episode. type RememberEpisodeResponse struct { // MemoryID is the unique identifier for this episode MemoryID string `json:"memory_id"` // StorageConfidence reflects Synap's assessment of storage quality StorageConfidence ConfidenceScore `json:"storage_confidence"` // ConsolidationState indicates memory age ("Recent", "Consolidating", "Semantic") ConsolidationState string `json:"consolidation_state"` // ObservedAt is when the event originally occurred ObservedAt time.Time `json:"observed_at"` // StoredAt is when Synap stored the memory StoredAt time.Time `json:"stored_at"` // SystemMessage provides feedback about storage SystemMessage string `json:"system_message"` } // ConfidenceScore represents Synap's confidence in a memory or recall. type ConfidenceScore struct { // Value is the numeric confidence (0.0-1.0) Value float64 `json:"value"` // Category is the human-readable category ("Low", "Medium", "High", "Very High") Category string `json:"category"` // Reasoning explains how confidence was calculated (optional) Reasoning string `json:"reasoning,omitempty"` } // RememberResponse contains the result of storing a simple semantic memory. type RememberResponse struct { // MemoryID is the unique identifier for this memory MemoryID string `json:"memory_id"` // ObservedAt is when the memory was created ObservedAt time.Time `json:"observed_at"` // StoredAt is when Synap stored the memory StoredAt time.Time `json:"stored_at"` // StorageConfidence reflects Synap's assessment of storage quality StorageConfidence ConfidenceScore `json:"storage_confidence"` } // Remember stores a simple semantic memory in Synap. // // This is a convenience method for storing straightforward facts or observations // without the full episodic context (what/when/where/who/why/how). // For richer contextual memories, use RememberEpisode instead. // // The memory will be stored in the space specified in the client config, // or you can override it with WithSpace in the context. // // Example: // // memoryID, err := client.Remember(ctx, "User loves hiking and photography", 0.85) func (c *Client) Remember(ctx context.Context, content string, confidence float64) (*RememberResponse, error) { if content == "" { return nil, fmt.Errorf("content is required") } if confidence == 0 { confidence = 0.7 // Default confidence } space := getSpace(ctx, c.config.DefaultSpace) endpoint := c.buildURL("/api/v1/memories/remember", map[string]string{ "space": space, }) reqBody := map[string]interface{}{ "content": content, "confidence": confidence, } body, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") if c.config.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.config.APIKey) } c.logger.Debug("storing semantic memory in synap", "content_length", len(content), "space", space, "confidence", confidence) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr) } return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes) } var result RememberResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } c.logger.Info("semantic memory stored in synap", "memory_id", result.MemoryID, "confidence", result.StorageConfidence.Value, "space", space) return &result, nil } // RememberEpisode stores an episodic memory in Synap. // // The memory will be stored in the space specified in the client config, // or you can override it with WithSpace in the context. // // Example: // // episode := &synap.Episode{ // What: "User mentioned they love hiking and photography", // When: time.Now(), // Who: []string{"user_alice"}, // Confidence: 0.85, // } // memoryID, err := client.RememberEpisode(ctx, episode) func (c *Client) RememberEpisode(ctx context.Context, episode *Episode) (*RememberEpisodeResponse, error) { if episode.What == "" { return nil, fmt.Errorf("episode.What is required") } if episode.When.IsZero() { return nil, fmt.Errorf("episode.When is required") } if episode.Confidence == 0 { episode.Confidence = 0.7 // Default confidence } space := getSpace(ctx, c.config.DefaultSpace) endpoint := c.buildURL("/api/v1/episodes/remember", map[string]string{ "space": space, }) body, err := json.Marshal(episode) if err != nil { return nil, fmt.Errorf("marshal episode: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") if c.config.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.config.APIKey) } c.logger.Debug("storing episode in synap", "what", episode.What, "space", space, "confidence", episode.Confidence) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr) } return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes) } var result RememberEpisodeResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } c.logger.Info("episode stored in synap", "memory_id", result.MemoryID, "confidence", result.StorageConfidence.Value, "space", space) return &result, nil } // RecallMode specifies how memories should be retrieved. type RecallMode string const ( // RecallModeSimilarity uses vector similarity search only RecallModeSimilarity RecallMode = "similarity" // RecallModeSpreading uses spreading activation through memory graph RecallModeSpreading RecallMode = "spreading" // RecallModeHybrid combines similarity and spreading (recommended for chat) RecallModeHybrid RecallMode = "hybrid" ) // RecallRequest specifies parameters for memory recall. type RecallRequest struct { // Query is the natural language search query (required) Query string // Mode specifies recall strategy (default: hybrid) Mode RecallMode // MaxResults limits the number of memories returned (default: 10) MaxResults int // Threshold is the minimum confidence for results (0.0-1.0, default: 0.5) Threshold float64 // FromTime filters memories after this time (optional) FromTime *time.Time // ToTime filters memories before this time (optional) ToTime *time.Time // RequiredTags filters to memories with these tags (optional) RequiredTags []string // ExcludedTags filters out memories with these tags (optional) ExcludedTags []string // TraceActivation returns activation spreading details (for debugging) TraceActivation bool } // Memory represents a recalled memory from Synap. type Memory struct { // ID is the unique memory identifier ID string `json:"id"` // Content is the memory content (for semantic memories) // For episodes, use Episode field instead Content string `json:"content,omitempty"` // Episode contains episodic memory details (what/when/where/who/why/how) Episode *EpisodeDetails `json:"episode,omitempty"` // Confidence in the memory accuracy Confidence ConfidenceScore `json:"confidence"` // ActivationLevel shows how "awake" this memory is (0.0-1.0) // Higher activation = more recently/frequently accessed ActivationLevel float64 `json:"activation_level"` // SimilarityScore shows relevance to the query (0.0-1.0) SimilarityScore float64 `json:"similarity_score"` // ObservedAt is when the original event occurred ObservedAt time.Time `json:"observed_at,omitempty"` // Tags categorize the memory Tags []string `json:"tags,omitempty"` } // EpisodeDetails contains the rich context of an episodic memory. type EpisodeDetails struct { What string `json:"what"` When time.Time `json:"when"` Where string `json:"where,omitempty"` Who []string `json:"who,omitempty"` Why string `json:"why,omitempty"` How string `json:"how,omitempty"` } // RecallResponse contains memories recalled from Synap. type RecallResponse struct { // Memories organized by vividness Memories MemoryCategories `json:"memories"` // RecallConfidence indicates overall recall quality RecallConfidence ConfidenceScore `json:"recall_confidence"` // QueryAnalysis provides insights into the search QueryAnalysis QueryAnalysis `json:"query_analysis"` // SystemMessage provides feedback SystemMessage string `json:"system_message"` } // MemoryCategories groups memories by their vividness/confidence. type MemoryCategories struct { // Vivid memories are high-confidence, directly relevant Vivid []Memory `json:"vivid"` // Associated memories are related through spreading activation Associated []Memory `json:"associated"` // Reconstructed memories are pattern-completed from partial information Reconstructed []Memory `json:"reconstructed"` } // QueryAnalysis provides insights into how the query was processed. type QueryAnalysis struct { // UnderstoodIntent is Synap's interpretation of the query UnderstoodIntent string `json:"understood_intent"` // SearchStrategy describes the retrieval approach SearchStrategy string `json:"search_strategy"` // CognitiveLoad indicates query complexity ("Low", "Medium", "High") CognitiveLoad string `json:"cognitive_load"` // Suggestions for improving recall (optional) Suggestions []string `json:"suggestions,omitempty"` } // Recall retrieves relevant memories from Synap. // // Example: // // memories, err := client.Recall(ctx, &synap.RecallRequest{ // Query: "hobbies and interests", // Mode: synap.RecallModeHybrid, // MaxResults: 10, // Threshold: 0.5, // }) // // for _, memory := range memories.Memories.Vivid { // fmt.Printf("Memory: %s (confidence: %.2f)\n", memory.Content, memory.Confidence.Value) // } func (c *Client) Recall(ctx context.Context, req *RecallRequest) (*RecallResponse, error) { if req.Query == "" { return nil, fmt.Errorf("query is required") } if req.Mode == "" { req.Mode = RecallModeHybrid } if req.MaxResults == 0 { req.MaxResults = 10 } if req.Threshold == 0 { req.Threshold = 0.5 } space := getSpace(ctx, c.config.DefaultSpace) // Build query parameters params := map[string]string{ "query": req.Query, "mode": string(req.Mode), "max_results": fmt.Sprintf("%d", req.MaxResults), "threshold": fmt.Sprintf("%.2f", req.Threshold), "space": space, } if req.FromTime != nil { params["from_time"] = req.FromTime.Format(time.RFC3339) } if req.ToTime != nil { params["to_time"] = req.ToTime.Format(time.RFC3339) } if len(req.RequiredTags) > 0 { params["required_tags"] = strings.Join(req.RequiredTags, ",") } if len(req.ExcludedTags) > 0 { params["excluded_tags"] = strings.Join(req.ExcludedTags, ",") } if req.TraceActivation { params["trace_activation"] = "true" } endpoint := c.buildURL("/api/v1/memories/recall", params) httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if c.config.APIKey != "" { httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey) } c.logger.Debug("recalling memories from synap", "query", req.Query, "mode", req.Mode, "space", space) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("synap API error (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr) } return nil, fmt.Errorf("synap API error (HTTP %d): %s", resp.StatusCode, bodyBytes) } var result RecallResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } totalMemories := len(result.Memories.Vivid) + len(result.Memories.Associated) + len(result.Memories.Reconstructed) c.logger.Info("recalled memories from synap", "total", totalMemories, "vivid", len(result.Memories.Vivid), "associated", len(result.Memories.Associated), "confidence", result.RecallConfidence.Value, "space", space) return &result, nil } // Health checks if the Synap server is reachable and healthy. func (c *Client) Health(ctx context.Context) error { endpoint := c.baseURL + "/api/v1/system/health" req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return fmt.Errorf("create request: %w", err) } if c.config.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.config.APIKey) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, readErr := io.ReadAll(resp.Body) if readErr != nil { return fmt.Errorf("synap health check failed (HTTP %d): failed to read error body: %w", resp.StatusCode, readErr) } return fmt.Errorf("synap health check failed (HTTP %d): %s", resp.StatusCode, bodyBytes) } return nil } // buildURL constructs a URL with query parameters. func (c *Client) buildURL(path string, params map[string]string) string { u, _ := url.Parse(c.baseURL + path) q := u.Query() for k, v := range params { if v != "" { q.Set(k, v) } } u.RawQuery = q.Encode() return u.String() } // Context keys for per-request configuration. type contextKey string const spaceContextKey contextKey = "synap_space" // WithSpace returns a context with a specific memory space override. // // This overrides the default space configured in the client for a single request. // // Example: // // ctx := synap.WithSpace(ctx, "conversation_xyz") // client.RememberEpisode(ctx, episode) // Uses conversation_xyz space func WithSpace(ctx context.Context, space string) context.Context { return context.WithValue(ctx, spaceContextKey, space) } // getSpace retrieves the space from context, falling back to default. func getSpace(ctx context.Context, defaultSpace string) string { if space, ok := ctx.Value(spaceContextKey).(string); ok && space != "" { return space } return defaultSpace }