persona-community-5/pkg/synap/client.go
jordan bd2f591b98
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-24 07:39:46 +00:00

671 lines
19 KiB
Go

// 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-5/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 <key>" 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
}