Skip to content

Instantly share code, notes, and snippets.

@CiprianSpiridon
Last active October 28, 2025 10:24
Show Gist options
  • Select an option

  • Save CiprianSpiridon/41e0fc32b6d5669622aaeb4c7244765c to your computer and use it in GitHub Desktop.

Select an option

Save CiprianSpiridon/41e0fc32b6d5669622aaeb4c7244765c to your computer and use it in GitHub Desktop.

ULPI Memory System - Complete Implementation Plan

Executive Summary

Build a Laravel-native Memory system that uses HSG (Hierarchical Semantic Graph) architecture within ULPI. This system enables AI agents to store, retrieve, and manage memories across five cognitive sectors with intelligent decay, semantic connections, and hybrid search capabilities.

Key Decisions:

  • Configurable Classification: Free tier uses regex (fast, zero cost), paid tier uses LLM (higher accuracy)
  • Hybrid subscription model: Plan-based feature access with included quotas + metered overage billing

Table of Contents

  1. Architecture Overview
  2. Technical Stack
  3. HSG (Hierarchical Semantic Graph) Explained
  4. Classification System (Regex vs LLM)
  5. Database Schema
  6. Typesense Collections
  7. Core Systems
  8. Implementation Roadmap (10 Weeks)
  9. File Structure (76 Files)
  10. Code Examples
  11. Testing Strategy
  12. Deployment & Operations
  13. Success Metrics

Architecture Overview

System Components

┌──────────────────────────────────────────────────────────────────┐
│                      ULPI Memory System                           │
├──────────────────────────────────────────────────────────────────┤
│                                                                    │
│  ┌─────────────┐      ┌──────────────┐      ┌──────────────────┐│
│  │   Laravel   │─────▶│  Typesense   │◀─────│  External APIs   ││
│  │   Backend   │      │  (5 sectors) │      │                  ││
│  └─────────────┘      └──────────────┘      │ - OpenAI         ││
│        │                      │              │   (embeddings +  ││
│        │                      │              │    classification││
│  ┌─────▼──────┐      ┌───────▼────────┐    │    GPT-4o-mini   ││
│  │   MySQL    │      │  Redis Cache   │    │    default)      ││
│  │ (metadata) │      │  (search/temp) │    │ - Anthropic      ││
│  │            │      │                │    │   (optional)     ││
│  └────────────┘      └────────────────┘    └──────────────────┘│
│                                                                    │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │              MCP Server (6 Tools + 4 Resources)             │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                    │
└──────────────────────────────────────────────────────────────────┘

Data Flow: Memory Creation

User/Agent
    │
    ▼
Memory API (POST /api/memories)
    │
    ├──▶ Validate Input (FormRequest)
    │
    ├──▶ SectorClassificationService.classify()
    │    │
    │    ├──▶ Check tenant's classification_method
    │    │    │
    │    │    ├──▶ FREE TIER (regex): Pattern matching (27 regex patterns)
    │    │    │    Cost: $0, Latency: ~1ms
    │    │    │
    │    │    └──▶ PAID TIER (llm): LLM classification
    │    │         Provider: OpenAI GPT-4o-mini (default) or Anthropic Claude Haiku
    │    │         Cost: ~$0.00003 per memory (GPT-4o-mini), Latency: ~300ms
    │    │         Fallback to regex on failure
    │    │
    │    └──▶ Output: primary_sector + additional_sectors + confidence
    │
    ├──▶ EmbeddingService.generateEmbedding()
    │    │
    │    ├──▶ TypesenseEmbeddingProvider (default)
    │    │    └──▶ Typesense auto-embed API
    │    │
    │    └──▶ OpenAIEmbeddingProvider (optional)
    │         └──▶ OpenAI text-embedding-3-large
    │
    ├──▶ Memory::create() [MySQL]
    │    │
    │    └──▶ Stores: content, primary_sector, tags, meta,
    │              salience, decay_lambda, mean_vector (1536-dim)
    │
    ├──▶ Event: MemoryCreated
    │
    └──▶ IndexMemoryJob dispatched (queued)
         │
         └──▶ For each sector [episodic, semantic, procedural, emotional, reflective]:
              │
              └──▶ Typesense upsert to tenant_{id}_memories_{sector}
                   Documents created: 5 (one per sector collection)

Result: Memory stored in MySQL + indexed in 5 Typesense collections

Data Flow: Memory Search

Search Request (query, filters, limit)
    │
    ▼
MemorySearchService.search()
    │
    ├──▶ Parallel search across 5 sector collections
    │    │
    │    ├──▶ tenant_{id}_memories_episodic
    │    ├──▶ tenant_{id}_memories_semantic
    │    ├──▶ tenant_{id}_memories_procedural
    │    ├──▶ tenant_{id}_memories_emotional
    │    └──▶ tenant_{id}_memories_reflective
    │
    ├──▶ Vector similarity search (Typesense)
    │    └──▶ Returns: memory_id, vector_similarity, current_salience, created_at
    │
    ├──▶ Deduplicate by memory_id (keep highest sector score)
    │
    ├──▶ Waypoint Expansion (optional, if depth > 0)
    │    │
    │    └──▶ BFS traversal of memory_waypoints table
    │         Max depth: 3 hops
    │         Max results: 10 per hop
    │         Weight decay: 20% per hop
    │
    ├──▶ Hybrid Scoring Algorithm
    │    │
    │    └──▶ final_score = 0.6 * vector_similarity
    │                      + 0.2 * current_salience
    │                      + 0.1 * recency_score
    │                      + 0.1 * waypoint_weight
    │
    ├──▶ Sort by final_score DESC
    │
    ├──▶ Apply limit
    │
    └──▶ Hydrate from MySQL (eager load waypoints, tags)

Result: Ranked memories with hybrid scores

Data Flow: Memory Decay (Daily Job)

Schedule: Daily at 02:00 UTC
    │
    ▼
DecayMemoriesJob dispatched
    │
    ├──▶ Query memories updated > 24h ago
    │
    ├──▶ Process in chunks (1000 per batch)
    │
    ├──▶ For each memory:
    │    │
    │    ├──▶ Calculate days_since_last_seen
    │    │
    │    ├──▶ Apply decay formula:
    │    │    current_salience = initial_salience * exp(-decay_lambda * days)
    │    │
    │    └──▶ Update MySQL: salience, last_decayed_at
    │
    ├──▶ For each updated memory:
    │    │
    │    └──▶ Update Typesense (5 sector collections)
    │         Update salience field in all 5 documents
    │
    └──▶ Log results: processed_count, updated_count, duration

Result: Memories gradually lose salience over time (forgetting)

Technical Stack

Core Technologies

  • Backend: Laravel 12.x (PHP 8.2+)
  • Database: MySQL 8.0 (metadata, graph, logs)
  • Vector Storage: Typesense 26.0+ (multi-sector collections)
  • Cache: Redis 7.0+ (search results, temporary data)
  • Queue: Redis (Horizon) - background jobs
  • Embeddings: Typesense auto-embed (default) + OpenAI text-embedding-3-large (optional)

Why NOT Scout?

Decision: Use direct Typesense client (NOT Laravel Scout)

Rationale:

  1. Multi-sector indexing: Each memory must be indexed in 5 collections (one per sector). Scout's searchableAs() returns ONE collection name - can't create 5 documents.
  2. Consistency: Documentation module uses direct Typesense client - maintain same pattern.
  3. Control: Manual document management gives fine-grained control over multi-sector indexing.

Proof from codebase:

  • app/Services/Mcp/McpSearchService.php uses $this->typesense->collections[$collectionName]->documents->search()
  • app/Jobs/IndexDocumentationJob.php uses $typesense->collections[$collectionName]->documents->upsert()
  • app/Models/GeneratedDocumentation.php does NOT use Scout's Searchable trait

HSG (Hierarchical Semantic Graph) Explained

HSG = Hierarchical Semantic Graph

This is the core architecture.

Components

1. Hierarchical (5 Sectors)

Memory is categorized into 5 cognitive sectors inspired by cognitive psychology:

Sector Description Examples Decay Rate (λ) Weight
Episodic Personal experiences, events "Yesterday I met Sarah at the cafe" 0.015 1.2
Semantic Facts, knowledge, concepts "Paris is the capital of France" 0.005 1.0
Procedural How-to knowledge, skills "To make coffee: 1. Boil water 2. Add grounds" 0.008 1.1
Emotional Feelings, sentiments "I felt excited when I got the job offer" 0.020 1.3
Reflective Meta-cognition, insights "I learned that I work better in mornings" 0.001 0.8

Why 5 sectors?

  • Different types of memory decay at different rates (episodic fades faster than semantic)
  • Search relevance varies by sector (emotional memories weight higher for empathy tasks)
  • Multi-sector indexing improves recall (same memory accessible from different angles)

2. Semantic (Content-Based Classification)

Each memory is classified using 27 regex patterns (~5 per sector):

Episodic Patterns:

/\b(I|we|my|our)\s+(did|went|saw|met|talked|visited|experienced)/i
/\b(yesterday|today|last\s+(week|month|year)|ago)/i
/\b(happened|occurred|took\s+place|remember\s+when)/i
/\b(at\s+\d{1,2}:\d{2}|on\s+(Monday|Tuesday|Wednesday|...))/i
/\b(location:|place:|where:)/i

Semantic Patterns:

/\b(is|are|was|were|means|refers\s+to|defined\s+as)/i
/\b(fact:|note:|definition:|concept:|theory:)/i
/\b(always|never|all|every|none|generally|typically)/i
/\b(according\s+to|research\s+shows|studies\s+indicate)/i
/\b(characteristics?|properties|attributes|features)/i

Procedural Patterns:

/\b(how\s+to|step\s+\d+|first|then|next|finally)/i
/\b(procedure:|process:|method:|algorithm:|recipe:)/i
/\b(install|configure|setup|initialize|run|execute)/i
/\b(click|press|select|choose|enter|type)/i
/\b(repeat|loop|iterate|until|while)/i

Emotional Patterns:

/\b(feel|felt|feeling|emotion|mood)/i
/\b(happy|sad|angry|excited|anxious|frustrated|proud)/i
/\b(love|hate|fear|joy|disgust|surprise)/i
/\b(sentiment:|emotion:|feeling:)/i
/\b(makes?\s+me|made\s+me)/i

Reflective Patterns:

/\b(I\s+(think|believe|realize|understand|learned))/i
/\b(reflection:|insight:|realization:|lesson:)/i
/\b(meta:|about\s+(thinking|learning|knowing))/i
/\b(why\s+(did\s+)?I|what\s+if|should\s+I\s+have)/i
/\b(pattern|tendency|habit|behavior|approach)/i

Classification Algorithm:

1. For each sector, count pattern matches weighted by sector.weight
2. Sort sectors by score
3. Primary sector = highest scoring sector
4. Additional sectors = any sector scoring ≥30% of primary score
5. Confidence = (primary_score - second_score) / primary_score

Example:

Content: "Yesterday I learned that I work better in the mornings. I felt productive and focused."

Pattern Matches:
- Episodic: "Yesterday" (1 match × 1.2 = 1.2)
- Semantic: "learned" (1 match × 1.0 = 1.0)
- Reflective: "I learned", "work better" (2 matches × 0.8 = 1.6)
- Emotional: "felt", "productive" (2 matches × 1.3 = 2.6)

Primary: Emotional (2.6)
Additional: Reflective (1.6 ≥ 30% of 2.6 = 0.78) ✓
           Episodic (1.2 ≥ 0.78) ✓

Result: primary_sector=emotional, additional_sectors=[reflective, episodic]

3. Graph (Waypoint Connections)

Memories are connected via waypoints - semantic relationships based on vector similarity.

Waypoint Creation:

1. For each new memory M:
2. Search for similar memories using vector similarity
3. For each similar memory S where cosine_similarity(M, S) ≥ 0.75:
4. Create bidirectional waypoint:
   - memory_waypoints: {src_id: M, dst_id: S, weight: similarity}
   - memory_waypoints: {src_id: S, dst_id: M, weight: similarity}

Waypoint Expansion (BFS):

During search, optionally expand via waypoints:
1. Start with direct search results (depth 0)
2. For each result R:
3. Traverse waypoints up to max_depth hops
4. At each hop, decay waypoint weight by 20%
5. Limit to max 10 new memories per hop
6. Avoid cycles (track visited)

Example:
Query: "coffee"
Direct results: [M1: "I love coffee", M2: "Coffee keeps me awake"]
Depth 1 expansion: M1 → M3 ("caffeine effects"), M2 → M4 ("morning routine")
Depth 2 expansion: M3 → M5 ("energy drinks"), M4 → M6 ("breakfast habits")

Waypoint weights:
- M1, M2: 1.0 (direct search)
- M3, M4: 0.8 (depth 1, 20% decay)
- M5, M6: 0.64 (depth 2, 20% decay)

Why Waypoints?

  • Improves recall by traversing semantic relationships
  • Discovers related memories not directly matching the query
  • Mimics human associative memory (one thought triggers another)

Classification System (Regex vs LLM)

Overview

The Memory system supports two classification methods for categorizing content into cognitive sectors:

  1. Regex Classification (FREE) - Pattern-based, fast, deterministic
  2. LLM Classification (PAID) - AI-powered, accurate, context-aware

Default LLM Provider: OpenAI GPT-4o-mini

  • Cost: ~$0.00003 per classification (94% cheaper than Claude Haiku)
  • Speed: ~300ms response time
  • Quality: 95%+ accuracy on sector classification
  • Alternative: Anthropic Claude 3.5 Haiku available if preferred

Comparison Table

Feature Regex (Free) LLM (Paid)
Speed ~1ms ~300ms
Cost $0 ~$0.00003 per memory (GPT-4o-mini)
Accuracy ~70-75% ~95%+
Language English only Multilingual
Context Pattern-based Semantic understanding
Ambiguity Low confidence on edge cases High confidence
Offline Yes No (requires API)

Pricing Model: Hybrid Subscription

Subscription Plans:

Plan Classification Method Included Quota Overage Cost
Starter Regex only Unlimited (free) N/A
Pro LLM (GPT-4o-mini) 1,000/month $0.00003 per classification
Enterprise LLM (GPT-4o-mini) 10,000/month $0.00002 per classification

How It Works:

  1. Plan-based access: LLM classification enabled only for Pro/Enterprise plans
  2. Included quota: Each plan includes a monthly classification quota
  3. Metered overage: Usage beyond quota is billed per classification via Stripe
  4. Cost tracking: All LLM classifications logged for transparency

Example Billing (Pro Plan):

  • Base cost: $99/month
  • Included: 1,000 LLM classifications (GPT-4o-mini)
  • Usage: 1,500 classifications
  • Overage: 500 × $0.00003 = $0.015
  • Total: $99.015/month (~$99/month)

Regex Classification (Default)

How It Works:

1. Count pattern matches for each sector (weighted by sector.weight)
2. Sort sectors by score
3. Primary = highest scoring sector
4. Additional = any sector scoring ≥30% of primary score
5. Confidence = (primary_score - second_score) / primary_score

27 Regex Patterns (5 per sector):

  • Episodic: Personal experiences, temporal/spatial markers
  • Semantic: Facts, definitions, knowledge statements
  • Procedural: How-to, step-by-step instructions
  • Emotional: Feelings, sentiments, affective language
  • Reflective: Meta-cognition, insights, self-analysis

Advantages: ✅ Zero cost ✅ Instant classification ✅ No API dependencies ✅ Deterministic (same input → same output) ✅ Privacy (no data sent externally)

Limitations: ❌ Lower accuracy (~70%) ❌ English-only ❌ Misses nuanced context ❌ Poor handling of ambiguous content

LLM Classification (Premium)

How It Works:

1. Check tenant subscription (Pro/Enterprise required)
2. Check monthly quota (track usage)
3. Call LLM API with classification prompt
4. Parse structured JSON response
5. Log classification (cost tracking)
6. Report usage to Stripe (if over quota)
7. Fallback to regex on failure

Supported Providers:

  • OpenAI GPT-4o-mini (default): $0.00015 per 1K input tokens (~200 tokens per classification = $0.00003)
  • Anthropic Claude 3.5 Haiku: $0.00025 per 1K input tokens (~200 tokens per classification = $0.0005)

Classification Prompt:

Classify the following memory into ONE primary cognitive sector and any relevant additional sectors.

Sectors:
- episodic: Personal experiences, events with time/place
- semantic: Facts, knowledge, concepts
- procedural: How-to knowledge, skills, procedures
- emotional: Feelings, sentiments, affective states
- reflective: Meta-cognition, insights, self-reflection

Memory:
"{content}"

Return JSON only:
{
    "primary": "sector_name",
    "additional": ["sector_name"],
    "confidence": 0.95,
    "reasoning": "brief explanation"
}

Advantages: ✅ High accuracy (~95%+) ✅ Multilingual support ✅ Contextual understanding ✅ Handles ambiguity well ✅ Returns confidence + reasoning

Limitations: ❌ Costs money (~$0.0005 per classification) ❌ Slower (~300ms latency) ❌ Requires API connectivity ❌ Non-deterministic (slight variations)

Configuration

Tenant-Level Setting:

// Determined by active subscription
$tenant->getMemoryClassificationMethod(); // 'regex' or 'llm'

Product Features (config/subscription-plans.php):

'starter' => [
    'features' => [
        'memory_classification_method' => 'regex',  // Free tier
    ],
],

'pro' => [
    'features' => [
        'memory_classification_method' => 'llm',  // Paid tier
        'llm_classifications_included' => 1000,   // Monthly quota
    ],
],

Environment Variables:

# For LLM classification
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...  # optional alternative
MEMORY_CLASSIFICATION_LLM_PROVIDER=openai  # default: 'openai', or 'anthropic'
MEMORY_CLASSIFICATION_LLM_MODEL=gpt-4o-mini  # default: 'gpt-4o-mini', or 'claude-3-5-haiku-20241022'

Implementation

Service: SectorClassificationService

public function classify(string $content, Tenant $tenant): array
{
    $method = $tenant->getMemoryClassificationMethod(); // 'regex' or 'llm'

    // Select provider
    $provider = match($method) {
        'regex' => app(RegexClassificationProvider::class),
        'llm' => app(LLMClassificationProvider::class),
        default => app(RegexClassificationProvider::class),
    };

    // Check quota (if LLM)
    if ($method === 'llm') {
        $usage = $this->getClassificationUsageThisMonth($tenant);
        $quota = $tenant->getLlmClassificationQuota();

        if ($usage >= $quota) {
            // Over quota: report metered usage to Stripe
            $this->billingService->reportClassificationUsage($tenant, 1);
        }
    }

    $startTime = microtime(true);

    try {
        $result = $provider->classify($content);
        $duration = (int)((microtime(true) - $startTime) * 1000);

        // Log classification (especially for cost tracking)
        $this->logClassification($tenant, $result, $provider, $duration, 'success');

        return $result;
    } catch (\Exception $e) {
        $duration = (int)((microtime(true) - $startTime) * 1000);
        $this->logClassification($tenant, [], $provider, $duration, 'failed', $e->getMessage());

        // Fallback to regex on LLM failure
        if ($method === 'llm') {
            Log::warning("LLM classification failed for tenant {$tenant->id}, falling back to regex");
            return app(RegexClassificationProvider::class)->classify($content);
        }

        throw $e;
    }
}

Quota Management

Tracking Usage:

// Count classifications this billing period
$usage = MemoryClassificationLog::where('tenant_id', $tenant->id)
    ->where('method', 'llm')
    ->where('status', 'success')
    ->whereBetween('created_at', [
        $billingPeriodStart,
        $billingPeriodEnd,
    ])
    ->count();

Quota Enforcement:

  • Under quota: Use LLM (included in subscription)
  • Over quota: Use LLM + report to Stripe (metered billing)
  • No access: Downgrade to regex (free)

Admin Dashboard:

  • Widget showing: "LLM Classifications: 750 / 1,000 (75%)"
  • Alert at 90% usage
  • Upgrade prompt when quota exceeded

Cost Tracking

New Table: memory_classification_logs

CREATE TABLE memory_classification_logs (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    memory_id BIGINT NOT NULL,

    -- Classification Info
    method VARCHAR(50) NOT NULL,  -- 'regex', 'llm'
    provider VARCHAR(50),          -- 'openai', 'anthropic', null
    model VARCHAR(100),            -- 'gpt-4o-mini' (default), 'claude-3-5-haiku-20241022', etc.

    -- Results
    primary_sector VARCHAR(50) NOT NULL,
    additional_sectors JSON,
    confidence DECIMAL(5,4) NOT NULL,
    reasoning TEXT,                -- LLM only

    -- Metrics
    token_count INT,               -- LLM only
    duration_ms INT NOT NULL,
    cost_usd DECIMAL(10,6),        -- LLM only

    -- Status
    status ENUM('success', 'failed') NOT NULL,
    error_message TEXT,

    created_at TIMESTAMP NOT NULL,

    INDEX (tenant_id, created_at),
    INDEX (method, status),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
);

Purpose:

  • Cost transparency: Show exact LLM classification costs
  • Quality metrics: Track confidence scores by method
  • Debugging: Analyze failure reasons
  • Compliance: Audit trail for billing

Migration Path

For Existing Free Users:

  1. Continue using regex (no change)
  2. Upgrade to Pro → automatic LLM classification
  3. No data migration needed

For New Pro Users:

  1. Sign up with Pro plan
  2. LLM classification enabled immediately
  3. First 1,000 classifications included

Downgrade:

  1. Pro → Starter: Switch to regex (instant)
  2. Historical memories keep original classification
  3. New memories use regex going forward

Database Schema

Table: memories

Primary metadata storage for all memories.

CREATE TABLE memories (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NOT NULL,

    -- Content
    content TEXT NOT NULL,
    tags JSON DEFAULT NULL,
    meta JSON DEFAULT NULL,

    -- Classification
    primary_sector ENUM('episodic', 'semantic', 'procedural', 'emotional', 'reflective') NOT NULL,
    additional_sectors JSON DEFAULT NULL,
    classification_confidence DECIMAL(5,4) DEFAULT NULL,

    -- Embedding
    embedding_provider VARCHAR(50) NOT NULL DEFAULT 'typesense',
    mean_vector JSON NOT NULL, -- 1536-dimensional array

    -- Salience & Decay
    salience DECIMAL(5,4) NOT NULL DEFAULT 1.0000,
    decay_lambda DECIMAL(6,5) NOT NULL,
    initial_salience DECIMAL(5,4) NOT NULL DEFAULT 1.0000,

    -- Access Tracking
    access_count INT UNSIGNED NOT NULL DEFAULT 0,
    last_accessed_at TIMESTAMP NULL DEFAULT NULL,
    last_decayed_at TIMESTAMP NULL DEFAULT NULL,

    -- Timestamps
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    -- Indexes
    INDEX idx_tenant_sector (tenant_id, primary_sector),
    INDEX idx_tenant_salience (tenant_id, salience),
    INDEX idx_tenant_created (tenant_id, created_at),
    INDEX idx_last_accessed (last_accessed_at),

    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Key Fields:

  • mean_vector: 1536-dimensional embedding array (JSON) - averaged across all sectors if using multi-sector embeddings
  • salience: Current importance score (0.0 to 1.0) - decays over time
  • decay_lambda: Sector-specific decay rate (0.001 to 0.020)
  • additional_sectors: Array of secondary sector classifications
  • classification_confidence: How confident the classifier is (higher = more certain)

Table: memory_waypoints

Graph connections between semantically related memories.

CREATE TABLE memory_waypoints (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NOT NULL,

    -- Graph Edge
    src_memory_id BIGINT UNSIGNED NOT NULL,
    dst_memory_id BIGINT UNSIGNED NOT NULL,

    -- Edge Weight
    weight DECIMAL(5,4) NOT NULL, -- Cosine similarity (0.75 to 1.0)

    -- Metadata
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    -- Indexes
    INDEX idx_src_weight (src_memory_id, weight DESC),
    INDEX idx_dst_weight (dst_memory_id, weight DESC),
    INDEX idx_tenant (tenant_id),
    UNIQUE KEY unique_waypoint (src_memory_id, dst_memory_id),

    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (src_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
    FOREIGN KEY (dst_memory_id) REFERENCES memories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Key Features:

  • Bidirectional edges (if A → B exists, B → A also exists)
  • Weight represents semantic similarity (cosine similarity ≥ 0.75)
  • Indexed for fast BFS traversal

Table: memory_embedding_logs

Tracks embedding generation for cost monitoring and debugging.

CREATE TABLE memory_embedding_logs (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT UNSIGNED NOT NULL,
    memory_id BIGINT UNSIGNED NOT NULL,

    -- Provider Info
    provider VARCHAR(50) NOT NULL, -- 'typesense', 'openai'
    model VARCHAR(100) DEFAULT NULL, -- 'text-embedding-3-large', etc.

    -- Metrics
    token_count INT UNSIGNED DEFAULT NULL,
    duration_ms INT UNSIGNED NOT NULL,

    -- Cost (if applicable)
    cost_usd DECIMAL(10,6) DEFAULT NULL,

    -- Status
    status ENUM('success', 'failed') NOT NULL,
    error_message TEXT DEFAULT NULL,

    -- Timestamp
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    -- Indexes
    INDEX idx_tenant (tenant_id),
    INDEX idx_memory (memory_id),
    INDEX idx_created (created_at),
    INDEX idx_provider (provider, created_at),

    FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
    FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Purpose:

  • Cost tracking for OpenAI API usage
  • Performance monitoring (duration_ms)
  • Debugging embedding failures
  • Audit trail for compliance

Table: tenants (Update)

Add memory collection tracking to existing tenants table.

ALTER TABLE tenants ADD COLUMN memory_collections JSON DEFAULT NULL AFTER search_collection;

Example value:

{
  "episodic": "tenant_1_memories_episodic",
  "semantic": "tenant_1_memories_semantic",
  "procedural": "tenant_1_memories_procedural",
  "emotional": "tenant_1_memories_emotional",
  "reflective": "tenant_1_memories_reflective"
}

Typesense Collections

Collection Structure

Each tenant has 5 Typesense collections (one per sector):

tenant_1_memories_episodic
tenant_1_memories_semantic
tenant_1_memories_procedural
tenant_1_memories_emotional
tenant_1_memories_reflective

Collection Schema

{
  "name": "tenant_{tenant_id}_memories_{sector}",
  "fields": [
    {
      "name": "memory_id",
      "type": "int64",
      "facet": false,
      "index": true
    },
    {
      "name": "content",
      "type": "string",
      "facet": false,
      "index": true
    },
    {
      "name": "embedding",
      "type": "float[]",
      "embed": {
        "from": ["content"],
        "model_config": {
          "model_name": "ts/all-MiniLM-L12-v2",
          "indexing_prefix": "",
          "query_prefix": ""
        }
      },
      "num_dim": 384
    },
    {
      "name": "salience",
      "type": "float",
      "facet": false,
      "index": true
    },
    {
      "name": "sector",
      "type": "string",
      "facet": true,
      "index": true
    },
    {
      "name": "tags",
      "type": "string[]",
      "facet": true,
      "optional": true
    },
    {
      "name": "created_at",
      "type": "int64",
      "facet": false,
      "index": true
    }
  ],
  "default_sorting_field": "created_at"
}

Key Configuration:

  • Embedding field: Uses Typesense auto-embed from content field
  • Vector dimensions: 384 (Typesense default) or 1536 (OpenAI)
  • Document ID format: {memory_id}_{sector} (e.g., "123_episodic")
  • Facets: sector and tags for filtering

Multi-Sector Indexing

Each memory creates 5 documents in Typesense:

// Memory ID: 123, Content: "Yesterday I learned about Paris"

Document 1: tenant_1_memories_episodic
{
  "id": "123_episodic",
  "memory_id": 123,
  "content": "Yesterday I learned about Paris",
  "embedding": [0.123, -0.456, ...], // 384 or 1536 dims
  "salience": 1.0,
  "sector": "episodic",
  "tags": ["travel", "learning"],
  "created_at": 1730419200
}

Document 2: tenant_1_memories_semantic
{
  "id": "123_semantic",
  "memory_id": 123,
  // ... same content, different sector
  "sector": "semantic"
}

// ... 3 more documents for procedural, emotional, reflective

Why 5 documents per memory?

  • Each sector collection uses sector-tuned embeddings (future enhancement)
  • Sector-specific filtering and boosting
  • Different decay rates per sector
  • Independent re-ranking based on sector importance

Core Systems

1. Sector Classification System

Service: SectorClassificationService

Algorithm:

public function classify(string $content): array
{
    $scores = [];

    foreach (config('memory.sectors') as $sector => $config) {
        $score = 0;

        foreach ($config['patterns'] as $pattern) {
            preg_match_all($pattern, $content, $matches);
            $score += count($matches[0]) * $config['weight'];
        }

        $scores[$sector] = $score;
    }

    arsort($scores);
    $sortedScores = array_values($scores);
    $sortedSectors = array_keys($scores);

    $primarySector = $sortedSectors[0];
    $primaryScore = $sortedScores[0];
    $secondScore = $sortedScores[1] ?? 0;

    $confidence = $primaryScore > 0
        ? ($primaryScore - $secondScore) / $primaryScore
        : 0;

    $threshold = max(1, $primaryScore * 0.3);
    $additionalSectors = [];

    for ($i = 1; $i < count($sortedScores); $i++) {
        if ($sortedScores[$i] >= $threshold) {
            $additionalSectors[] = $sortedSectors[$i];
        }
    }

    return [
        'primary' => $primarySector,
        'additional' => $additionalSectors,
        'confidence' => round($confidence, 4),
        'scores' => $scores,
    ];
}

Configuration: config/memory.php

return [
    'sectors' => [
        'episodic' => [
            'patterns' => [
                '/\b(I|we|my|our)\s+(did|went|saw|met|talked|visited|experienced)/i',
                '/\b(yesterday|today|last\s+(week|month|year)|ago)/i',
                '/\b(happened|occurred|took\s+place|remember\s+when)/i',
                '/\b(at\s+\d{1,2}:\d{2}|on\s+(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday))/i',
                '/\b(location:|place:|where:)/i',
            ],
            'decay_lambda' => 0.015,
            'weight' => 1.2,
            'description' => 'Personal experiences and specific events with temporal/spatial context',
        ],
        // ... 4 more sectors
    ],
];

2. Embedding System

Service: EmbeddingService

Providers:

TypesenseEmbeddingProvider (Default)

  • Model: ts/all-MiniLM-L12-v2
  • Dimensions: 384
  • Cost: $0 (free)
  • Latency: ~50ms
  • Method: Typesense auto-embed API
public function generate(string $content): array
{
    $response = Http::post($this->typesenseUrl . '/embeddings', [
        'model' => 'ts/all-MiniLM-L12-v2',
        'input' => $content,
    ]);

    return $response->json('embedding');
}

OpenAIEmbeddingProvider (Optional)

  • Model: text-embedding-3-large
  • Dimensions: 1536
  • Cost: $0.00013 per 1K tokens
  • Latency: ~200ms
  • Method: OpenAI Embeddings API
public function generate(string $content): array
{
    $response = $this->client->embeddings()->create([
        'model' => 'text-embedding-3-large',
        'input' => $content,
    ]);

    return $response->embeddings[0]->embedding;
}

Provider Selection:

// In .env
MEMORY_EMBEDDING_PROVIDER=typesense  # or 'openai'
OPENAI_API_KEY=sk-...  # only if provider=openai

3. Search System

Service: MemorySearchService

Search Process:

public function search(
    Tenant $tenant,
    string $query,
    array $filters = [],
    int $limit = 10,
    int $waypointDepth = 0
): Collection {
    // Step 1: Parallel multi-sector search
    $sectorResults = $this->searchAllSectors($tenant, $query, $filters, $limit * 3);

    // Step 2: Deduplicate by memory_id
    $uniqueResults = $this->deduplicateResults($sectorResults);

    // Step 3: Optional waypoint expansion
    if ($waypointDepth > 0) {
        $expandedResults = $this->expandViaWaypoints($uniqueResults, $waypointDepth);
        $uniqueResults = $uniqueResults->merge($expandedResults);
    }

    // Step 4: Hybrid scoring
    $scoredResults = $uniqueResults->map(function ($result) {
        return $this->calculateHybridScore($result);
    });

    // Step 5: Sort and limit
    return $scoredResults
        ->sortByDesc('final_score')
        ->take($limit)
        ->values();
}

Hybrid Scoring Formula:

private function calculateHybridScore(array $result): array
{
    $vectorSimilarity = $result['vector_similarity']; // 0.0 to 1.0
    $salience = $result['salience']; // 0.0 to 1.0
    $daysSince = $result['days_since_created'];
    $waypointWeight = $result['waypoint_weight'] ?? 0;

    $recencyScore = exp(-$daysSince / 30); // Exponential decay over 30 days

    $finalScore =
        0.6 * $vectorSimilarity +
        0.2 * $salience +
        0.1 * $recencyScore +
        0.1 * $waypointWeight;

    $result['final_score'] = round($finalScore, 4);
    $result['score_breakdown'] = [
        'vector' => round(0.6 * $vectorSimilarity, 4),
        'salience' => round(0.2 * $salience, 4),
        'recency' => round(0.1 * $recencyScore, 4),
        'waypoint' => round(0.1 * $waypointWeight, 4),
    ];

    return $result;
}

Waypoint Expansion (BFS):

private function expandViaWaypoints(Collection $seedResults, int maxDepth): Collection
{
    $expanded = collect();
    $visited = $seedResults->pluck('memory_id')->toArray();
    $queue = $seedResults->map(fn($r) => ['memory_id' => $r['memory_id'], 'depth' => 0, 'weight' => 1.0]);

    while ($queue->isNotEmpty() && $maxDepth > 0) {
        $current = $queue->shift();

        if ($current['depth'] >= $maxDepth) continue;

        $waypoints = MemoryWaypoint::where('src_memory_id', $current['memory_id'])
            ->whereNotIn('dst_memory_id', $visited)
            ->orderBy('weight', 'desc')
            ->limit(10)
            ->get();

        foreach ($waypoints as $waypoint) {
            $visited[] = $waypoint->dst_memory_id;

            $decayedWeight = $current['weight'] * 0.8; // 20% decay per hop

            $expanded->push([
                'memory_id' => $waypoint->dst_memory_id,
                'waypoint_weight' => $decayedWeight,
                'depth' => $current['depth'] + 1,
            ]);

            $queue->push([
                'memory_id' => $waypoint->dst_memory_id,
                'depth' => $current['depth'] + 1,
                'weight' => $decayedWeight,
            ]);
        }
    }

    return $expanded;
}

4. Waypoint Graph System

Service: WaypointService

Building Waypoints:

public function buildWaypointsForMemory(Memory $memory): int
{
    $created = 0;

    // Search for similar memories using vector search
    $similarMemories = $this->searchService->searchSimilar(
        $memory->tenant,
        $memory->mean_vector,
        limit: 50,
        minSimilarity: 0.75
    );

    foreach ($similarMemories as $similar) {
        if ($similar['memory_id'] === $memory->id) continue;

        $similarity = $similar['vector_similarity'];

        // Create bidirectional waypoint
        MemoryWaypoint::updateOrCreate(
            [
                'src_memory_id' => $memory->id,
                'dst_memory_id' => $similar['memory_id'],
            ],
            [
                'tenant_id' => $memory->tenant_id,
                'weight' => $similarity,
            ]
        );

        MemoryWaypoint::updateOrCreate(
            [
                'src_memory_id' => $similar['memory_id'],
                'dst_memory_id' => $memory->id,
            ],
            [
                'tenant_id' => $memory->tenant_id,
                'weight' => $similarity,
            ]
        );

        $created += 2;
    }

    return $created;
}

Batch Building (for existing memories):

public function buildAllWaypoints(Tenant $tenant): array
{
    $memories = Memory::where('tenant_id', $tenant->id)->get();
    $totalCreated = 0;

    foreach ($memories as $memory) {
        $created = $this->buildWaypointsForMemory($memory);
        $totalCreated += $created;
    }

    return [
        'memories_processed' => $memories->count(),
        'waypoints_created' => $totalCreated,
    ];
}

5. Memory Decay System

Job: DecayMemoriesJob

Scheduled: Daily at 02:00 UTC via Laravel scheduler

Process:

public function handle(): void
{
    $processed = 0;
    $updated = 0;
    $startTime = microtime(true);

    Memory::query()
        ->where('last_decayed_at', '<', now()->subDay())
        ->orWhereNull('last_decayed_at')
        ->chunkById(1000, function ($memories) use (&$processed, &$updated) {
            foreach ($memories as $memory) {
                $processed++;

                $daysSince = $memory->last_accessed_at
                    ? $memory->last_accessed_at->diffInDays(now())
                    : $memory->created_at->diffInDays(now());

                $decayLambda = config("memory.sectors.{$memory->primary_sector}.decay_lambda");

                $newSalience = $memory->initial_salience * exp(-$decayLambda * $daysSince);
                $newSalience = max(0, $newSalience);

                if (abs($memory->salience - $newSalience) > 0.0001) {
                    $memory->update([
                        'salience' => $newSalience,
                        'last_decayed_at' => now(),
                    ]);

                    // Update Typesense (all 5 sector collections)
                    $this->updateTypesenseSalience($memory, $newSalience);

                    $updated++;
                }
            }
        });

    $duration = round((microtime(true) - $startTime) * 1000);

    Log::info("Memory decay completed", [
        'processed' => $processed,
        'updated' => $updated,
        'duration_ms' => $duration,
    ]);
}

Decay Formula:

current_salience = initial_salience × e^(-λ × days_since_access)

Where:
- λ (lambda) = decay_lambda from sector config
- days_since_access = days since last_accessed_at (or created_at if never accessed)
- e = Euler's number (≈2.71828)

Example (Episodic, λ=0.015):
- Day 0: salience = 1.0
- Day 30: salience = 1.0 × e^(-0.015 × 30) = 0.638
- Day 60: salience = 1.0 × e^(-0.015 × 60) = 0.407
- Day 90: salience = 1.0 × e^(-0.015 × 90) = 0.260

6. Reinforcement Learning

Concept: Memories strengthen when accessed (mimics human memory consolidation).

Implementation:

// Memory model
public function access(): void
{
    $this->increment('access_count');

    // Reinforce salience (+0.1, max 1.0)
    $newSalience = min(1.0, $this->salience + 0.1);

    $this->update([
        'salience' => $newSalience,
        'last_accessed_at' => now(),
    ]);

    // Update Typesense
    $this->updateTypesenseSalience($newSalience);

    event(new MemoryAccessed($this));
}

Effect:

  • Each retrieval increases salience by +0.1 (capped at 1.0)
  • Frequently accessed memories resist decay
  • Mimics "spacing effect" in human memory

Implementation Roadmap (10 Weeks)

Week 1: Foundation (Database + Core Models)

Goal: Set up database schema and core Eloquent models.

Deliverables:

  1. Migration: 2025_11_01_000001_create_memories_table.php
  2. Migration: 2025_11_01_000002_create_memory_waypoints_table.php
  3. Migration: 2025_11_01_000003_create_memory_embedding_logs_table.php
  4. Migration: 2025_11_01_000004_add_memory_collections_to_tenants.php
  5. Model: app/Models/Memory.php
    • Relationships: tenant(), waypoints(), embeddingLogs()
    • Methods: access(), calculateDecay(), toTypesenseDocument()
    • Scopes: bySector(), bySalience(), recent()
  6. Model: app/Models/MemoryWaypoint.php
    • Relationships: srcMemory(), dstMemory(), tenant()
  7. Model: app/Models/MemoryEmbeddingLog.php
    • Relationships: memory(), tenant()
  8. Config: config/memory.php
    • 27 sector patterns (5 per sector)
    • Decay lambdas and weights
    • Waypoint threshold, max depth
    • Embedding provider settings
  9. Factory: database/factories/MemoryFactory.php
  10. Factory: database/factories/MemoryWaypointFactory.php
  11. Service: app/Services/Memory/SectorClassificationService.php
    • classify(string $content): array
    • Pattern matching algorithm

Tests:

  • ✅ Migrations run successfully
  • ✅ Models have correct relationships
  • ✅ SectorClassificationService classifies content correctly

Time Estimate: 5-7 days


Week 2: Collection Management + Indexing

Goal: Typesense collection lifecycle and multi-sector indexing.

Deliverables:

  1. Service: app/Services/Memory/TypesenseMemoryCollectionManager.php
    • createCollections(Tenant $tenant): array
    • deleteCollections(Tenant $tenant): void
    • getCollectionSchema(string $sector): array
  2. Service: app/Services/Memory/MemoryService.php
    • create(Tenant $tenant, array $data): Memory
    • update(Memory $memory, array $data): Memory
    • delete(Memory $memory): void
    • retrieve(Memory $memory): Memory (access tracking)
  3. Job: app/Jobs/Memory/IndexMemoryJob.php
    • Creates 5 Typesense documents (one per sector)
    • Handles embedding generation
    • Logs to memory_embedding_logs
  4. Job: app/Jobs/Memory/DeleteMemoryFromTypesenseJob.php
    • Deletes from all 5 sector collections
  5. Event: app/Events/MemoryCreated.php
  6. Event: app/Events/MemoryUpdated.php
  7. Event: app/Events/MemoryDeleted.php
  8. Listener: app/Listeners/IndexMemoryListener.php
    • Dispatches IndexMemoryJob on MemoryCreated
  9. Listener: app/Listeners/ReindexMemoryListener.php
    • Dispatches IndexMemoryJob on MemoryUpdated
  10. Listener: app/Listeners/DeleteMemoryFromTypesenseListener.php
    • Dispatches DeleteMemoryFromTypesenseJob on MemoryDeleted

Tests:

  • ✅ Collections created with correct schema
  • ✅ Memory indexed in all 5 sector collections
  • ✅ Memory updated in all 5 collections
  • ✅ Memory deleted from all 5 collections

Time Estimate: 6-8 days


Week 3: Embedding System

Goal: Multi-provider embedding generation with cost tracking.

Deliverables:

  1. Contract: app/Contracts/EmbeddingProvider.php
    • generate(string $content): array
    • getDimensions(): int
    • getModel(): string
  2. Provider: app/Services/Memory/Embeddings/TypesenseEmbeddingProvider.php
    • Uses Typesense auto-embed API
    • 384 dimensions
    • Free (no cost tracking)
  3. Provider: app/Services/Memory/Embeddings/OpenAIEmbeddingProvider.php
    • Uses OpenAI text-embedding-3-large
    • 1536 dimensions
    • Cost tracking: $0.00013 per 1K tokens
  4. Service: app/Services/Memory/EmbeddingService.php
    • generate(string $content): array
    • getProvider(): EmbeddingProvider
    • Logs to memory_embedding_logs
  5. Config Update: config/memory.php
    • Add embedding provider settings
  6. Command: php artisan memory:test-embeddings
    • Tests both providers
    • Compares outputs

Tests:

  • ✅ Typesense embeddings generated correctly
  • ✅ OpenAI embeddings generated correctly
  • ✅ Provider switching works
  • ✅ Cost calculated and logged
  • ✅ Failures logged with error messages

Time Estimate: 5-6 days


Week 4: Waypoint Graph System

Goal: Build semantic connections between memories.

Deliverables:

  1. Service: app/Services/Memory/WaypointService.php
    • buildWaypointsForMemory(Memory $memory): int
    • buildAllWaypoints(Tenant $tenant): array
    • findSimilarMemories(Memory $memory, float $threshold): Collection
    • cosineSimilarity(array $vectorA, array $vectorB): float
  2. Job: app/Jobs/Memory/BuildWaypointsJob.php
    • Batch processes memories
    • Creates bidirectional edges
    • Chunks by 100 memories
  3. Command: php artisan memory:build-waypoints {tenant_id?}
    • Build waypoints for all or specific tenant
    • --memory={id} flag for single memory
  4. Scheduled Task: Register in app/Console/Kernel.php
    • Weekly waypoint rebuild (Sundays at 03:00)

Tests:

  • ✅ Waypoints created bidirectionally
  • ✅ Similarity threshold enforced (≥0.75)
  • ✅ Batch processing works
  • ✅ Command executes successfully

Time Estimate: 5-7 days


Week 5: Search System (Part 1 - Vector Search)

Goal: Multi-sector parallel vector search with salience/recency scoring.

Deliverables:

  1. Service: app/Services/Memory/MemorySearchService.php
    • search(Tenant $tenant, string $query, array $filters, int $limit): Collection
    • searchAllSectors(Tenant $tenant, string $query): Collection
    • deduplicateResults(Collection $results): Collection
  2. DTO: app/DTOs/Memory/SearchQuery.php
    • Properties: query, filters, limit, waypointDepth, sectorBoosts
  3. DTO: app/DTOs/Memory/SearchResult.php
    • Properties: memory, vectorSimilarity, salience, recency, finalScore, scoreBreakdown
  4. DTO: app/DTOs/Memory/ScoringWeights.php
    • Properties: similarity, salience, recency, waypoint

Tests:

  • ✅ Vector search returns relevant results
  • ✅ Multi-sector search works in parallel
  • ✅ Deduplication keeps highest score
  • ✅ Salience/recency scoring correct

Time Estimate: 6-8 days


Week 6: Search System (Part 2 - Hybrid Scoring + Waypoints)

Goal: Complete hybrid scoring algorithm with waypoint expansion.

Deliverables:

  1. Service Update: MemorySearchService.php
    • calculateHybridScore(array $result): array
    • expandViaWaypoints(Collection $seedResults, int $maxDepth): Collection
    • bfsTraversal(array $seeds, int $maxDepth): Collection
  2. Algorithm: Hybrid scoring formula implementation
  3. Algorithm: BFS waypoint expansion with decay
  4. Benchmark: tests/Performance/SearchBenchmarkTest.php
    • Measure search latency at various memory counts
    • Target: <200ms p95 for 10 results

Tests:

  • ✅ Hybrid scoring formula correct
  • ✅ Waypoint expansion discovers related memories
  • ✅ BFS respects max depth
  • ✅ Weight decays 20% per hop
  • ✅ Performance meets target (<200ms p95)

Time Estimate: 6-8 days


Week 7: Memory Decay System

Goal: Automated memory decay with batch processing.

Deliverables:

  1. Job: app/Jobs/Memory/DecayMemoriesJob.php
    • Batch process 1000 memories at a time
    • Update MySQL and Typesense
    • Log results
  2. Command: php artisan memory:decay {tenant_id?}
    • Manual decay trigger
    • --force flag to override last_decayed_at check
  3. Scheduled Task: Register in app/Console/Kernel.php
    • Daily at 02:00 UTC
  4. Service Update: MemoryService.php
    • calculateDecay(Memory $memory): float
    • applyDecay(Memory $memory): void
  5. Monitoring: Log decay operations to storage/logs/memory-decay.log

Tests:

  • ✅ Decay formula correct for all sectors
  • ✅ Salience decreases over time
  • ✅ Batch processing handles large datasets
  • ✅ Typesense updated correctly
  • ✅ Scheduled task runs daily

Time Estimate: 5-6 days


Week 8: MCP Server + REST API

Goal: MCP tools/resources and REST API endpoints.

Deliverables:

MCP Server (6 Tools + 4 Resources)

  1. Server: app/Mcp/Servers/MemoryServer.php
  2. Tool: app/Mcp/Tools/Memory/StoreMemoryTool.php
    • Parameters: content, tags[], meta{}
    • Returns: Memory object with ID
  3. Tool: app/Mcp/Tools/Memory/SearchMemoriesTool.php
    • Parameters: query, limit, filters{}, waypointDepth
    • Returns: Array of memories with scores
  4. Tool: app/Mcp/Tools/Memory/RetrieveMemoryTool.php
    • Parameters: id
    • Returns: Memory object (triggers reinforcement)
  5. Tool: app/Mcp/Tools/Memory/ReinforceMemoryTool.php
    • Parameters: id
    • Returns: Updated salience
  6. Tool: app/Mcp/Tools/Memory/PruneMemoriesTool.php
    • Parameters: threshold (min salience to keep)
    • Returns: Count of pruned memories
  7. Tool: app/Mcp/Tools/Memory/AnalyzeMemoryTool.php
    • Parameters: id
    • Returns: Classification, waypoints, access stats
  8. Resource: app/Mcp/Resources/Memory/MemoryResource.php
    • URI: memory/{id}
  9. Resource: app/Mcp/Resources/Memory/MemoriesResource.php
    • URI: memories?sector={sector}&limit={limit}
  10. Resource: app/Mcp/Resources/Memory/WaypointsResource.php
    • URI: waypoints/{memory_id}
  11. Resource: app/Mcp/Resources/Memory/StatsResource.php
    • URI: stats (tenant memory statistics)

REST API

  1. Controller: app/Http/Controllers/Api/MemoryController.php
    • index() - List memories
    • store() - Create memory
    • show() - Get memory (with access tracking)
    • update() - Update memory
    • destroy() - Delete memory
    • search() - Search memories
  2. Request: app/Http/Requests/Memory/StoreMemoryRequest.php
  3. Request: app/Http/Requests/Memory/UpdateMemoryRequest.php
  4. Request: app/Http/Requests/Memory/SearchMemoryRequest.php
  5. Resource: app/Http/Resources/MemoryResource.php
  6. Resource: app/Http/Resources/MemoryCollection.php
  7. Routes: Add to routes/api.php

Tests:

  • ✅ MCP tools work via MCP protocol
  • ✅ REST API endpoints return correct responses
  • ✅ Authentication/authorization works
  • ✅ Validation prevents invalid data

Time Estimate: 8-10 days


Week 9: Admin Panel (Filament)

Goal: Filament admin interface for memory management.

Deliverables:

  1. Resource: app/Filament/Resources/MemoryResource.php
    • Table: ID, content preview, sector, salience, access count, created_at
    • Filters: Sector, salience range, date range
    • Actions: View, Edit, Delete, Reinforce
    • Bulk actions: Delete, Reinforce, Prune by threshold
  2. Pages: CRUD pages (List, Create, Edit, View)
  3. Resource: app/Filament/Resources/MemoryWaypointResource.php
    • Table: Source memory, destination memory, weight
    • Filters: Weight range, memory ID
  4. Widget: app/Filament/Widgets/MemoryStatsWidget.php
    • Total memories by tenant
    • Average salience by sector
    • Total waypoints
    • Embedding logs (success/failure ratio)
  5. Widget: app/Filament/Widgets/SectorDistributionWidget.php
    • Pie chart of memories by sector
  6. Widget: app/Filament/Widgets/DecayMonitoringWidget.php
    • Last decay run timestamp
    • Memories decayed in last run
    • Average salience trend

Tests:

  • ✅ Admin can view memories
  • ✅ Admin can create/edit/delete memories
  • ✅ Filters work correctly
  • ✅ Bulk actions execute
  • ✅ Widgets display correct data

Time Estimate: 5-6 days


Week 10: Testing + Documentation + Polish

Goal: Achieve 80%+ test coverage, complete documentation, final polish.

Deliverables:

Testing

  1. Integration Tests: tests/Feature/Memory/MemoryWorkflowTest.php
    • End-to-end: Create → Index → Search → Access → Decay
  2. Performance Tests: tests/Performance/MemoryPerformanceTest.php
    • Search latency benchmarks
    • Indexing speed tests
    • Decay job performance
  3. Test Coverage Report: Run php artisan test --coverage --min=80

Documentation

  1. Setup Guide: docs/memory/setup.md
    • Installation steps
    • Configuration options
    • Collection creation
  2. API Documentation: docs/memory/api.md
    • REST API endpoints
    • Request/response examples
  3. MCP Guide: docs/memory/mcp.md
    • MCP server configuration
    • Tool/resource specifications
    • Example workflows
  4. User Guide: docs/memory/user-guide.md
    • Concepts (sectors, waypoints, decay)
    • Best practices
    • Troubleshooting

Seeders

  1. Seeder: database/seeders/MemorySeeder.php
    • Creates 100 sample memories per tenant
    • Builds waypoints
    • Various sectors and salience levels

Polish

  1. Code Review: PSR-12 compliance via Pint
  2. Optimization: Query optimization, caching
  3. Bug Fixes: Fix any issues found during testing
  4. Final Testing: QA pass on all features

Tests:

  • ✅ 80%+ test coverage achieved
  • ✅ All integration tests pass
  • ✅ Performance targets met
  • ✅ Documentation complete and accurate

Time Estimate: 6-8 days


File Structure (70 Files)

Database (4 migrations)

database/
├── migrations/
│   ├── 2025_11_01_000001_create_memories_table.php
│   ├── 2025_11_01_000002_create_memory_waypoints_table.php
│   ├── 2025_11_01_000003_create_memory_embedding_logs_table.php
│   └── 2025_11_01_000004_add_memory_collections_to_tenants.php
├── factories/
│   ├── MemoryFactory.php
│   ├── MemoryWaypointFactory.php
│   └── MemoryEmbeddingLogFactory.php
└── seeders/
    └── MemorySeeder.php

Models (3 models)

app/Models/
├── Memory.php
├── MemoryWaypoint.php
└── MemoryEmbeddingLog.php

Services (7 services)

app/Services/Memory/
├── MemoryService.php
├── SectorClassificationService.php
├── TypesenseMemoryCollectionManager.php
├── EmbeddingService.php
├── WaypointService.php
├── MemorySearchService.php
└── Embeddings/
    ├── TypesenseEmbeddingProvider.php
    └── OpenAIEmbeddingProvider.php

Contracts (1 interface)

app/Contracts/
└── EmbeddingProvider.php

DTOs (3 DTOs)

app/DTOs/Memory/
├── SearchQuery.php
├── SearchResult.php
└── ScoringWeights.php

Jobs (4 jobs)

app/Jobs/Memory/
├── IndexMemoryJob.php
├── DeleteMemoryFromTypesenseJob.php
├── BuildWaypointsJob.php
└── DecayMemoriesJob.php

Events (3 events)

app/Events/
├── MemoryCreated.php
├── MemoryUpdated.php
└── MemoryDeleted.php

Listeners (3 listeners)

app/Listeners/
├── IndexMemoryListener.php
├── ReindexMemoryListener.php
└── DeleteMemoryFromTypesenseListener.php

Commands (3 commands)

app/Console/Commands/Memory/
├── BuildWaypointsCommand.php
├── DecayMemoriesCommand.php
└── TestEmbeddingsCommand.php

MCP Server (6 tools + 4 resources + 1 server)

app/Mcp/
├── Servers/
│   └── MemoryServer.php
├── Tools/Memory/
│   ├── StoreMemoryTool.php
│   ├── SearchMemoriesTool.php
│   ├── RetrieveMemoryTool.php
│   ├── ReinforceMemoryTool.php
│   ├── PruneMemoriesTool.php
│   └── AnalyzeMemoryTool.php
└── Resources/Memory/
    ├── MemoryResource.php
    ├── MemoriesResource.php
    ├── WaypointsResource.php
    └── StatsResource.php

REST API (1 controller + 3 requests + 2 resources)

app/Http/
├── Controllers/Api/
│   └── MemoryController.php
├── Requests/Memory/
│   ├── StoreMemoryRequest.php
│   ├── UpdateMemoryRequest.php
│   └── SearchMemoryRequest.php
└── Resources/
    ├── MemoryResource.php
    └── MemoryCollection.php

Filament Admin (2 resources + 3 widgets + 6 pages)

app/Filament/
├── Resources/
│   ├── MemoryResource.php
│   ├── MemoryResource/
│   │   ├── Pages/
│   │   │   ├── ListMemories.php
│   │   │   ├── CreateMemory.php
│   │   │   ├── EditMemory.php
│   │   │   └── ViewMemory.php
│   │   └── RelationManagers/
│   │       └── WaypointsRelationManager.php
│   ├── MemoryWaypointResource.php
│   └── MemoryWaypointResource/
│       └── Pages/
│           └── ListMemoryWaypoints.php
└── Widgets/
    ├── MemoryStatsWidget.php
    ├── SectorDistributionWidget.php
    └── DecayMonitoringWidget.php

Configuration (1 config file)

config/
└── memory.php

Tests (8 test files)

tests/
├── Feature/Memory/
│   ├── MemoryWorkflowTest.php
│   ├── MemoryCrudTest.php
│   ├── MemorySearchTest.php
│   └── MemoryApiTest.php
├── Unit/Memory/
│   ├── SectorClassificationTest.php
│   ├── EmbeddingServiceTest.php
│   ├── WaypointServiceTest.php
│   └── DecayCalculationTest.php
└── Performance/
    └── MemoryPerformanceTest.php

Documentation (4 docs)

docs/memory/
├── setup.md
├── api.md
├── mcp.md
└── user-guide.md

Code Examples

1. Memory Model

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Memory extends Model
{
    use HasFactory;

    protected $fillable = [
        'tenant_id',
        'content',
        'tags',
        'meta',
        'primary_sector',
        'additional_sectors',
        'classification_confidence',
        'embedding_provider',
        'mean_vector',
        'salience',
        'decay_lambda',
        'initial_salience',
        'access_count',
        'last_accessed_at',
        'last_decayed_at',
    ];

    protected $casts = [
        'tags' => 'array',
        'meta' => 'array',
        'additional_sectors' => 'array',
        'mean_vector' => 'array',
        'salience' => 'decimal:4',
        'decay_lambda' => 'decimal:5',
        'initial_salience' => 'decimal:4',
        'classification_confidence' => 'decimal:4',
        'access_count' => 'integer',
        'last_accessed_at' => 'datetime',
        'last_decayed_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    // Relationships
    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function waypoints(): HasMany
    {
        return $this->hasMany(MemoryWaypoint::class, 'src_memory_id');
    }

    public function embeddingLogs(): HasMany
    {
        return $this->hasMany(MemoryEmbeddingLog::class);
    }

    // Scopes
    public function scopeBySector($query, string $sector)
    {
        return $query->where('primary_sector', $sector);
    }

    public function scopeBySalience($query, float $min, float $max = 1.0)
    {
        return $query->whereBetween('salience', [$min, $max]);
    }

    public function scopeRecent($query, int $days = 7)
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }

    // Methods

    /**
     * Access this memory (triggers reinforcement).
     */
    public function access(): void
    {
        $this->increment('access_count');

        // Reinforce salience (+0.1, max 1.0)
        $newSalience = min(1.0, $this->salience + 0.1);

        $this->update([
            'salience' => $newSalience,
            'last_accessed_at' => now(),
        ]);

        event(new \App\Events\MemoryAccessed($this));
    }

    /**
     * Calculate current salience with decay.
     */
    public function calculateDecay(): float
    {
        $daysSince = $this->last_accessed_at
            ? $this->last_accessed_at->diffInDays(now())
            : $this->created_at->diffInDays(now());

        $decayed = $this->initial_salience * exp(-$this->decay_lambda * $daysSince);

        return max(0, $decayed);
    }

    /**
     * Convert to Typesense document format.
     */
    public function toTypesenseDocument(string $sector): array
    {
        return [
            'id' => "{$this->id}_{$sector}",
            'memory_id' => $this->id,
            'content' => $this->content,
            'embedding' => $this->mean_vector,
            'salience' => (float) $this->salience,
            'sector' => $sector,
            'tags' => $this->tags ?? [],
            'created_at' => $this->created_at->timestamp,
        ];
    }
}

2. SectorClassificationService

<?php

declare(strict_types=1);

namespace App\Services\Memory;

class SectorClassificationService
{
    public function classify(string $content): array
    {
        $scores = [];
        $sectors = config('memory.sectors');

        foreach ($sectors as $sector => $config) {
            $score = 0;

            foreach ($config['patterns'] as $pattern) {
                preg_match_all($pattern, $content, $matches);
                $matchCount = count($matches[0]);
                $score += $matchCount * $config['weight'];
            }

            $scores[$sector] = $score;
        }

        // Sort by score descending
        arsort($scores);
        $sortedScores = array_values($scores);
        $sortedSectors = array_keys($scores);

        $primarySector = $sortedSectors[0];
        $primaryScore = $sortedScores[0];
        $secondScore = $sortedScores[1] ?? 0;

        // Calculate confidence (0.0 to 1.0)
        $confidence = $primaryScore > 0
            ? ($primaryScore - $secondScore) / $primaryScore
            : 0;

        // Determine additional sectors (scoring ≥30% of primary)
        $threshold = max(1, $primaryScore * 0.3);
        $additionalSectors = [];

        for ($i = 1; $i < count($sortedScores); $i++) {
            if ($sortedScores[$i] >= $threshold) {
                $additionalSectors[] = $sortedSectors[$i];
            }
        }

        return [
            'primary' => $primarySector,
            'additional' => $additionalSectors,
            'confidence' => round($confidence, 4),
            'scores' => $scores,
        ];
    }
}

3. IndexMemoryJob

<?php

declare(strict_types=1);

namespace App\Jobs\Memory;

use App\Models\Memory;
use App\Services\Memory\EmbeddingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Typesense\Client as TypesenseClient;

class IndexMemoryJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 300;

    public function __construct(
        private readonly int $memoryId
    ) {}

    public function handle(
        TypesenseClient $typesense,
        EmbeddingService $embeddingService
    ): void {
        $memory = Memory::with('tenant')->findOrFail($this->memoryId);
        $tenant = $memory->tenant;

        $collections = $tenant->memory_collections;

        if (empty($collections)) {
            throw new \Exception("Memory collections not initialized for tenant {$tenant->id}");
        }

        // Generate embedding if not already present
        if (empty($memory->mean_vector)) {
            $startTime = microtime(true);

            try {
                $embedding = $embeddingService->generate($memory->content);

                $memory->update([
                    'mean_vector' => $embedding,
                ]);

                $duration = (int) ((microtime(true) - $startTime) * 1000);

                // Log embedding generation
                $memory->embeddingLogs()->create([
                    'tenant_id' => $tenant->id,
                    'provider' => config('memory.embedding.provider'),
                    'model' => $embeddingService->getProvider()->getModel(),
                    'duration_ms' => $duration,
                    'status' => 'success',
                ]);
            } catch (\Exception $e) {
                $duration = (int) ((microtime(true) - $startTime) * 1000);

                $memory->embeddingLogs()->create([
                    'tenant_id' => $tenant->id,
                    'provider' => config('memory.embedding.provider'),
                    'duration_ms' => $duration,
                    'status' => 'failed',
                    'error_message' => $e->getMessage(),
                ]);

                throw $e;
            }
        }

        // Index in all 5 sector collections
        $sectors = ['episodic', 'semantic', 'procedural', 'emotional', 'reflective'];

        foreach ($sectors as $sector) {
            $collectionName = $collections[$sector];
            $document = $memory->toTypesenseDocument($sector);

            $typesense->collections[$collectionName]->documents->upsert($document);
        }

        \Log::info("Memory indexed", [
            'memory_id' => $memory->id,
            'tenant_id' => $tenant->id,
            'sector' => $memory->primary_sector,
            'collections' => count($sectors),
        ]);
    }
}

4. MemorySearchService

<?php

declare(strict_types=1);

namespace App\Services\Memory;

use App\Models\Memory;
use App\Models\MemoryWaypoint;
use App\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Typesense\Client as TypesenseClient;

class MemorySearchService
{
    public function __construct(
        private readonly TypesenseClient $typesense,
        private readonly EmbeddingService $embeddingService
    ) {}

    public function search(
        Tenant $tenant,
        string $query,
        array $filters = [],
        int $limit = 10,
        int $waypointDepth = 0
    ): Collection {
        // Step 1: Generate query embedding
        $queryEmbedding = $this->embeddingService->generate($query);

        // Step 2: Parallel multi-sector search
        $sectorResults = $this->searchAllSectors($tenant, $queryEmbedding, $filters, $limit * 3);

        // Step 3: Deduplicate by memory_id (keep highest score)
        $uniqueResults = $this->deduplicateResults($sectorResults);

        // Step 4: Optional waypoint expansion
        if ($waypointDepth > 0) {
            $expandedResults = $this->expandViaWaypoints($uniqueResults, $waypointDepth);
            $uniqueResults = $uniqueResults->merge($expandedResults);
        }

        // Step 5: Hybrid scoring
        $scoredResults = $uniqueResults->map(function ($result) {
            return $this->calculateHybridScore($result);
        });

        // Step 6: Sort by final_score and limit
        return $scoredResults
            ->sortByDesc('final_score')
            ->take($limit)
            ->values();
    }

    private function searchAllSectors(
        Tenant $tenant,
        array $queryEmbedding,
        array $filters,
        int $limit
    ): Collection {
        $collections = $tenant->memory_collections;
        $sectors = ['episodic', 'semantic', 'procedural', 'emotional', 'reflective'];

        $results = collect();

        foreach ($sectors as $sector) {
            $collectionName = $collections[$sector];

            $searchParams = [
                'q' => '*',
                'query_by' => 'embedding',
                'vector_query' => 'embedding:([' . implode(',', $queryEmbedding) . '], k:' . $limit . ')',
                'per_page' => $limit,
                'include_fields' => 'memory_id,salience,created_at',
            ];

            // Apply filters
            if (!empty($filters['tags'])) {
                $searchParams['filter_by'] = 'tags:=[' . implode(',', $filters['tags']) . ']';
            }

            try {
                $response = $this->typesense->collections[$collectionName]->documents->search($searchParams);

                foreach ($response['hits'] ?? [] as $hit) {
                    $results->push([
                        'memory_id' => $hit['document']['memory_id'],
                        'sector' => $sector,
                        'vector_similarity' => $hit['vector_distance'] ?? 0,
                        'salience' => $hit['document']['salience'],
                        'created_at' => $hit['document']['created_at'],
                        'waypoint_weight' => 0,
                    ]);
                }
            } catch (\Exception $e) {
                \Log::error("Sector search failed", [
                    'sector' => $sector,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        return $results;
    }

    private function deduplicateResults(Collection $results): Collection
    {
        return $results->groupBy('memory_id')->map(function ($group) {
            // Keep result with highest vector similarity
            return $group->sortByDesc('vector_similarity')->first();
        })->values();
    }

    private function expandViaWaypoints(Collection $seedResults, int $maxDepth): Collection
    {
        $expanded = collect();
        $visited = $seedResults->pluck('memory_id')->toArray();
        $queue = $seedResults->map(fn($r) => [
            'memory_id' => $r['memory_id'],
            'depth' => 0,
            'weight' => 1.0,
        ]);

        while ($queue->isNotEmpty() && $maxDepth > 0) {
            $current = $queue->shift();

            if ($current['depth'] >= $maxDepth) continue;

            $waypoints = MemoryWaypoint::where('src_memory_id', $current['memory_id'])
                ->whereNotIn('dst_memory_id', $visited)
                ->orderBy('weight', 'desc')
                ->limit(10)
                ->get();

            foreach ($waypoints as $waypoint) {
                $visited[] = $waypoint->dst_memory_id;

                $decayedWeight = $current['weight'] * 0.8; // 20% decay per hop

                $expanded->push([
                    'memory_id' => $waypoint->dst_memory_id,
                    'waypoint_weight' => $decayedWeight,
                    'depth' => $current['depth'] + 1,
                    'vector_similarity' => 0, // Not from vector search
                    'salience' => 0, // Will be fetched later
                    'created_at' => 0,
                ]);

                $queue->push([
                    'memory_id' => $waypoint->dst_memory_id,
                    'depth' => $current['depth'] + 1,
                    'weight' => $decayedWeight,
                ]);
            }
        }

        return $expanded;
    }

    private function calculateHybridScore(array $result): array
    {
        $vectorSimilarity = $result['vector_similarity'];
        $salience = $result['salience'];
        $createdAt = $result['created_at'];
        $waypointWeight = $result['waypoint_weight'];

        $daysSince = (time() - $createdAt) / 86400;
        $recencyScore = exp(-$daysSince / 30); // Exponential decay over 30 days

        $finalScore =
            0.6 * $vectorSimilarity +
            0.2 * $salience +
            0.1 * $recencyScore +
            0.1 * $waypointWeight;

        $result['final_score'] = round($finalScore, 4);
        $result['score_breakdown'] = [
            'vector' => round(0.6 * $vectorSimilarity, 4),
            'salience' => round(0.2 * $salience, 4),
            'recency' => round(0.1 * $recencyScore, 4),
            'waypoint' => round(0.1 * $waypointWeight, 4),
        ];

        return $result;
    }
}

Testing Strategy

Coverage Goals

  • Overall: 80%+ code coverage
  • Services: 90%+ (core business logic)
  • Controllers: 80%+ (API endpoints)
  • Jobs: 85%+ (background processing)
  • Models: 75%+ (methods and scopes)

Test Types

1. Unit Tests (tests/Unit/Memory/)

SectorClassificationTest.php:

  • Test all 27 patterns match correctly
  • Test primary sector selection
  • Test additional sectors threshold (30%)
  • Test confidence calculation
  • Edge cases: empty content, single word, multi-language

EmbeddingServiceTest.php:

  • Test Typesense provider generates 384-dim vectors
  • Test OpenAI provider generates 1536-dim vectors
  • Test provider switching
  • Test cost calculation
  • Test error handling (API failures)

WaypointServiceTest.php:

  • Test cosine similarity calculation
  • Test bidirectional waypoint creation
  • Test similarity threshold enforcement (≥0.75)
  • Test duplicate prevention

DecayCalculationTest.php:

  • Test decay formula for all 5 sectors
  • Test salience decreases over time
  • Test min salience is 0.0
  • Test different time intervals (1 day, 30 days, 90 days)

2. Feature Tests (tests/Feature/Memory/)

MemoryCrudTest.php:

  • Create memory via API
  • Update memory content
  • Delete memory
  • List memories with pagination
  • Retrieve single memory

MemorySearchTest.php:

  • Vector search returns relevant results
  • Multi-sector search works
  • Filters (tags, sector, salience range) work
  • Waypoint expansion discovers related memories
  • Hybrid scoring ranks correctly

MemoryApiTest.php:

  • Authentication required
  • Tenant isolation (can't access other tenant's memories)
  • Validation errors return 422
  • Rate limiting enforced

MemoryWorkflowTest.php (End-to-End):

test('complete memory lifecycle', function () {
    $tenant = Tenant::factory()->create();

    // 1. Create memory
    $response = $this->actingAs($tenant->users->first())
        ->postJson('/api/memories', [
            'content' => 'Yesterday I learned about Paris, the capital of France.',
            'tags' => ['travel', 'geography'],
        ]);

    $response->assertCreated();
    $memoryId = $response->json('data.id');

    // 2. Verify indexing (wait for job)
    $this->artisan('queue:work', ['--once' => true]);

    $memory = Memory::find($memoryId);
    expect($memory->mean_vector)->not->toBeEmpty();
    expect($memory->primary_sector)->toBeIn(['episodic', 'semantic']);

    // 3. Search for memory
    $searchResponse = $this->actingAs($tenant->users->first())
        ->postJson('/api/memories/search', [
            'query' => 'Paris capital',
            'limit' => 5,
        ]);

    $searchResponse->assertOk();
    $results = $searchResponse->json('data');
    expect($results)->toContain(fn($r) => $r['id'] === $memoryId);

    // 4. Access memory (reinforcement)
    $accessResponse = $this->actingAs($tenant->users->first())
        ->getJson("/api/memories/{$memoryId}");

    $accessResponse->assertOk();

    $memory->refresh();
    expect($memory->access_count)->toBe(1);
    expect($memory->salience)->toBeGreaterThan(1.0); // Reinforced

    // 5. Apply decay
    $memory->update(['last_accessed_at' => now()->subDays(30)]);

    $this->artisan('memory:decay');

    $memory->refresh();
    expect($memory->salience)->toBeLessThan($memory->initial_salience);
});

3. Performance Tests (tests/Performance/)

MemoryPerformanceTest.php:

test('search performance under load', function () {
    $tenant = Tenant::factory()->create();

    // Create 10,000 memories
    Memory::factory()->count(10000)->create(['tenant_id' => $tenant->id]);

    // Index all
    IndexMemoryJob::dispatchSync(...);

    // Benchmark search
    $start = microtime(true);

    $results = app(MemorySearchService::class)->search(
        $tenant,
        'test query',
        limit: 10
    );

    $duration = (microtime(true) - $start) * 1000;

    expect($duration)->toBeLessThan(200); // <200ms p95
    expect($results)->toHaveCount(10);
});

Deployment & Operations

Initial Setup

# 1. Run migrations
docker exec ulpi_api_dev php artisan migrate

# 2. Create Typesense collections for existing tenants
docker exec ulpi_api_dev php artisan memory:setup-collections

# 3. Seed demo data (optional)
docker exec ulpi_api_dev php artisan db:seed --class=MemorySeeder

# 4. Build waypoints for demo data
docker exec ulpi_api_dev php artisan memory:build-waypoints

# 5. Start queue workers (Horizon handles this automatically)
docker exec ulpi_api_dev php artisan horizon

# 6. Verify setup
docker exec ulpi_api_dev php artisan memory:health-check

Daily Operations

Scheduled Tasks (runs automatically):

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    // Memory decay - Daily at 02:00 UTC
    $schedule->job(DecayMemoriesJob::class)->dailyAt('02:00');

    // Waypoint rebuild - Weekly on Sundays at 03:00 UTC
    $schedule->command('memory:build-waypoints')->weekly()->sundays()->at('03:00');

    // Health check - Hourly
    $schedule->command('memory:health-check')->hourly();
}

Manual Operations:

# Force decay for all tenants
docker exec ulpi_api_dev php artisan memory:decay --force

# Rebuild waypoints for specific tenant
docker exec ulpi_api_dev php artisan memory:build-waypoints 123

# Prune low-salience memories (<0.1)
docker exec ulpi_api_dev php artisan memory:prune --threshold=0.1

# Reindex all memories
docker exec ulpi_api_dev php artisan memory:reindex

# View memory statistics
docker exec ulpi_api_dev php artisan memory:stats

Monitoring

Horizon Dashboard: /horizon

  • Monitor IndexMemoryJob queue
  • Monitor DecayMemoriesJob execution
  • Track failed jobs

Logs:

# Memory-specific logs
docker exec ulpi_api_dev tail -f storage/logs/memory-decay.log
docker exec ulpi_api_dev tail -f storage/logs/memory-waypoints.log

# General Laravel logs
docker exec ulpi_api_dev php artisan pail --filter="memory"

Key Metrics:

  • Memories per tenant (avg, max)
  • Indexing latency (p50, p95, p99)
  • Search latency (p50, p95, p99)
  • Decay job duration
  • Waypoint graph size
  • Embedding API costs (OpenAI)
  • Failed jobs count

Alerts:

  • Search latency >500ms
  • Indexing failures >5% rate
  • Decay job duration >10 minutes
  • Typesense collection errors

Success Metrics

Performance Targets

Metric Target Rationale
Search Latency <200ms (p95) Acceptable for real-time search
Indexing Speed <5s per memory (all 5 sectors) Acceptable background job latency
Waypoint Build <10min for 10k memories Weekly job can tolerate moderate latency
Decay Processing <5min for 100k memories Daily job with batch processing
Memory Usage <512MB for 100k memories cached Reasonable cache footprint

Quality Targets

Metric Target Rationale
Test Coverage >80% overall, >90% services High confidence in core logic
Search Relevance >70% user satisfaction Hybrid scoring improves accuracy
Uptime 99.9% Three nines for production SaaS
Error Rate <0.1% of requests Acceptable failure rate

Scale Targets

Metric Target Rationale
Memories per Tenant Support 1M+ Enterprise use cases
Concurrent Searches 1000 req/s High-traffic tenants
Tenants 10,000+ Multi-tenant SaaS scale
Typesense Storage <100GB for 1M memories Efficient vector storage

Risk Mitigation

Technical Risks

Risk 1: Typesense Downtime

  • Impact: Search unavailable
  • Mitigation:
    • Graceful degradation: Fall back to MySQL full-text search
    • Cache recent search results in Redis (TTL: 5 minutes)
    • Monitor Typesense health every minute

Risk 2: Embedding API Failures (OpenAI)

  • Impact: New memories can't be indexed
  • Mitigation:
    • Retry with exponential backoff (3 attempts)
    • Fall back to Typesense embeddings automatically
    • Queue failed embeddings for manual retry

Risk 3: Waypoint Graph Scale

  • Impact: BFS expansion becomes slow at scale
  • Mitigation:
    • Limit max waypoints per memory (default: 50)
    • Configurable BFS depth (default: 3, max: 5)
    • Prune low-weight waypoints (<0.75 similarity)

Risk 4: Memory Decay Performance

  • Impact: Daily job takes too long (>10 minutes)
  • Mitigation:
    • Batch processing in chunks of 1000
    • Parallelize Typesense updates
    • Run during low-traffic hours (02:00 UTC)

Risk 5: Multi-Tenancy Leakage

  • Impact: Tenant A sees Tenant B's memories
  • Mitigation:
    • Strict collection naming: tenant_{id}_memories_{sector}
    • Middleware validates tenant ownership
    • Integration tests verify isolation

Operational Risks

Risk 1: High OpenAI Costs

  • Impact: Unexpected billing spikes
  • Mitigation:
    • Default to Typesense embeddings (free)
    • Per-tenant embedding budget limits
    • Cost tracking in memory_embedding_logs

Risk 2: Typesense Disk Space

  • Impact: Collections can't grow
  • Mitigation:
    • Monitor disk usage (alert at 80%)
    • Prune memories with salience <0.05
    • Implement memory retention policies (e.g., 2 years)

Appendix: Configuration Reference

config/memory.php

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Memory Sectors Configuration
    |--------------------------------------------------------------------------
    |
    | Defines the 5 cognitive sectors with patterns, decay rates, and weights.
    |
    */
    'sectors' => [
        'episodic' => [
            'patterns' => [
                '/\b(I|we|my|our)\s+(did|went|saw|met|talked|visited|experienced)/i',
                '/\b(yesterday|today|last\s+(week|month|year)|ago)/i',
                '/\b(happened|occurred|took\s+place|remember\s+when)/i',
                '/\b(at\s+\d{1,2}:\d{2}|on\s+(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday))/i',
                '/\b(location:|place:|where:)/i',
            ],
            'decay_lambda' => 0.015,
            'weight' => 1.2,
            'description' => 'Personal experiences and specific events with temporal/spatial context',
        ],

        'semantic' => [
            'patterns' => [
                '/\b(is|are|was|were|means|refers\s+to|defined\s+as)/i',
                '/\b(fact:|note:|definition:|concept:|theory:)/i',
                '/\b(always|never|all|every|none|generally|typically)/i',
                '/\b(according\s+to|research\s+shows|studies\s+indicate)/i',
                '/\b(characteristics?|properties|attributes|features)/i',
            ],
            'decay_lambda' => 0.005,
            'weight' => 1.0,
            'description' => 'Facts, knowledge, and conceptual understanding',
        ],

        'procedural' => [
            'patterns' => [
                '/\b(how\s+to|step\s+\d+|first|then|next|finally)/i',
                '/\b(procedure:|process:|method:|algorithm:|recipe:)/i',
                '/\b(install|configure|setup|initialize|run|execute)/i',
                '/\b(click|press|select|choose|enter|type)/i',
                '/\b(repeat|loop|iterate|until|while)/i',
            ],
            'decay_lambda' => 0.008,
            'weight' => 1.1,
            'description' => 'How-to knowledge, skills, and procedures',
        ],

        'emotional' => [
            'patterns' => [
                '/\b(feel|felt|feeling|emotion|mood)/i',
                '/\b(happy|sad|angry|excited|anxious|frustrated|proud|disappointed)/i',
                '/\b(love|hate|fear|joy|disgust|surprise)/i',
                '/\b(sentiment:|emotion:|feeling:)/i',
                '/\b(makes?\s+me|made\s+me)/i',
            ],
            'decay_lambda' => 0.020,
            'weight' => 1.3,
            'description' => 'Emotional experiences and affective states',
        ],

        'reflective' => [
            'patterns' => [
                '/\b(I\s+(think|believe|realize|understand|learned))/i',
                '/\b(reflection:|insight:|realization:|lesson:)/i',
                '/\b(meta:|about\s+(thinking|learning|knowing))/i',
                '/\b(why\s+(did\s+)?I|what\s+if|should\s+I\s+have)/i',
                '/\b(pattern|tendency|habit|behavior|approach)/i',
            ],
            'decay_lambda' => 0.001,
            'weight' => 0.8,
            'description' => 'Meta-cognitive insights and self-reflection',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Waypoint Configuration
    |--------------------------------------------------------------------------
    |
    | Graph connection settings.
    |
    */
    'waypoints' => [
        'similarity_threshold' => 0.75, // Min cosine similarity for waypoint
        'max_connections_per_memory' => 50, // Limit graph size
        'max_depth' => 3, // Max BFS depth during search
        'weight_decay_per_hop' => 0.20, // 20% decay per hop
    ],

    /*
    |--------------------------------------------------------------------------
    | Search Configuration
    |--------------------------------------------------------------------------
    |
    | Hybrid scoring weights and search limits.
    |
    */
    'search' => [
        'scoring_weights' => [
            'vector_similarity' => 0.6,
            'salience' => 0.2,
            'recency' => 0.1,
            'waypoint' => 0.1,
        ],
        'default_limit' => 10,
        'max_limit' => 100,
        'recency_decay_days' => 30, // Exponential decay over 30 days
    ],

    /*
    |--------------------------------------------------------------------------
    | Embedding Configuration
    |--------------------------------------------------------------------------
    |
    | Embedding provider settings.
    |
    */
    'embedding' => [
        'provider' => env('MEMORY_EMBEDDING_PROVIDER', 'typesense'), // 'typesense' or 'openai'

        'typesense' => [
            'model' => 'ts/all-MiniLM-L12-v2',
            'dimensions' => 384,
        ],

        'openai' => [
            'model' => 'text-embedding-3-large',
            'dimensions' => 1536,
            'cost_per_1k_tokens' => 0.00013, // USD
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Decay Configuration
    |--------------------------------------------------------------------------
    |
    | Memory decay batch processing settings.
    |
    */
    'decay' => [
        'batch_size' => 1000, // Memories per chunk
        'schedule' => '02:00', // Daily at 02:00 UTC
    ],

    /*
    |--------------------------------------------------------------------------
    | Reinforcement Configuration
    |--------------------------------------------------------------------------
    |
    | Memory strengthening on access.
    |
    */
    'reinforcement' => [
        'salience_boost' => 0.1, // +0.1 per access
        'max_salience' => 1.0, // Cap at 1.0
    ],
];

End of Plan

This comprehensive 10-week plan provides everything needed to build a production-ready Memory system in ULPI that leverages HSG architecture using Laravel + Typesense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment