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
- Architecture Overview
- Technical Stack
- HSG (Hierarchical Semantic Graph) Explained
- Classification System (Regex vs LLM)
- Database Schema
- Typesense Collections
- Core Systems
- Implementation Roadmap (10 Weeks)
- File Structure (76 Files)
- Code Examples
- Testing Strategy
- Deployment & Operations
- Success Metrics
┌──────────────────────────────────────────────────────────────────┐
│ 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) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
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
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
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)
- 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)
Decision: Use direct Typesense client (NOT Laravel Scout)
Rationale:
- 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. - Consistency: Documentation module uses direct Typesense client - maintain same pattern.
- Control: Manual document management gives fine-grained control over multi-sector indexing.
Proof from codebase:
app/Services/Mcp/McpSearchService.phpuses$this->typesense->collections[$collectionName]->documents->search()app/Jobs/IndexDocumentationJob.phpuses$typesense->collections[$collectionName]->documents->upsert()app/Models/GeneratedDocumentation.phpdoes NOT use Scout'sSearchabletrait
HSG = Hierarchical Semantic Graph
This is the core architecture.
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)
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:)/iSemantic 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)/iProcedural 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)/iEmotional 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)/iReflective 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)/iClassification 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]
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)
The Memory system supports two classification methods for categorizing content into cognitive sectors:
- Regex Classification (FREE) - Pattern-based, fast, deterministic
- 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
| 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) |
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:
- Plan-based access: LLM classification enabled only for Pro/Enterprise plans
- Included quota: Each plan includes a monthly classification quota
- Metered overage: Usage beyond quota is billed per classification via Stripe
- 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)
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
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)
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'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;
}
}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
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
For Existing Free Users:
- Continue using regex (no change)
- Upgrade to Pro → automatic LLM classification
- No data migration needed
For New Pro Users:
- Sign up with Pro plan
- LLM classification enabled immediately
- First 1,000 classifications included
Downgrade:
- Pro → Starter: Switch to regex (instant)
- Historical memories keep original classification
- New memories use regex going forward
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 embeddingssalience: Current importance score (0.0 to 1.0) - decays over timedecay_lambda: Sector-specific decay rate (0.001 to 0.020)additional_sectors: Array of secondary sector classificationsclassification_confidence: How confident the classifier is (higher = more certain)
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
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
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"
}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
{
"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
contentfield - Vector dimensions: 384 (Typesense default) or 1536 (OpenAI)
- Document ID format:
{memory_id}_{sector}(e.g., "123_episodic") - Facets:
sectorandtagsfor filtering
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, reflectiveWhy 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
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
],
];Service: EmbeddingService
Providers:
- 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');
}- 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=openaiService: 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;
}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,
];
}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
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
Goal: Set up database schema and core Eloquent models.
Deliverables:
- Migration:
2025_11_01_000001_create_memories_table.php - Migration:
2025_11_01_000002_create_memory_waypoints_table.php - Migration:
2025_11_01_000003_create_memory_embedding_logs_table.php - Migration:
2025_11_01_000004_add_memory_collections_to_tenants.php - Model:
app/Models/Memory.php- Relationships:
tenant(),waypoints(),embeddingLogs() - Methods:
access(),calculateDecay(),toTypesenseDocument() - Scopes:
bySector(),bySalience(),recent()
- Relationships:
- Model:
app/Models/MemoryWaypoint.php- Relationships:
srcMemory(),dstMemory(),tenant()
- Relationships:
- Model:
app/Models/MemoryEmbeddingLog.php- Relationships:
memory(),tenant()
- Relationships:
- Config:
config/memory.php- 27 sector patterns (5 per sector)
- Decay lambdas and weights
- Waypoint threshold, max depth
- Embedding provider settings
- Factory:
database/factories/MemoryFactory.php - Factory:
database/factories/MemoryWaypointFactory.php - Service:
app/Services/Memory/SectorClassificationService.phpclassify(string $content): array- Pattern matching algorithm
Tests:
- ✅ Migrations run successfully
- ✅ Models have correct relationships
- ✅ SectorClassificationService classifies content correctly
Time Estimate: 5-7 days
Goal: Typesense collection lifecycle and multi-sector indexing.
Deliverables:
- Service:
app/Services/Memory/TypesenseMemoryCollectionManager.phpcreateCollections(Tenant $tenant): arraydeleteCollections(Tenant $tenant): voidgetCollectionSchema(string $sector): array
- Service:
app/Services/Memory/MemoryService.phpcreate(Tenant $tenant, array $data): Memoryupdate(Memory $memory, array $data): Memorydelete(Memory $memory): voidretrieve(Memory $memory): Memory(access tracking)
- Job:
app/Jobs/Memory/IndexMemoryJob.php- Creates 5 Typesense documents (one per sector)
- Handles embedding generation
- Logs to
memory_embedding_logs
- Job:
app/Jobs/Memory/DeleteMemoryFromTypesenseJob.php- Deletes from all 5 sector collections
- Event:
app/Events/MemoryCreated.php - Event:
app/Events/MemoryUpdated.php - Event:
app/Events/MemoryDeleted.php - Listener:
app/Listeners/IndexMemoryListener.php- Dispatches
IndexMemoryJobonMemoryCreated
- Dispatches
- Listener:
app/Listeners/ReindexMemoryListener.php- Dispatches
IndexMemoryJobonMemoryUpdated
- Dispatches
- Listener:
app/Listeners/DeleteMemoryFromTypesenseListener.php- Dispatches
DeleteMemoryFromTypesenseJobonMemoryDeleted
- Dispatches
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
Goal: Multi-provider embedding generation with cost tracking.
Deliverables:
- Contract:
app/Contracts/EmbeddingProvider.phpgenerate(string $content): arraygetDimensions(): intgetModel(): string
- Provider:
app/Services/Memory/Embeddings/TypesenseEmbeddingProvider.php- Uses Typesense auto-embed API
- 384 dimensions
- Free (no cost tracking)
- Provider:
app/Services/Memory/Embeddings/OpenAIEmbeddingProvider.php- Uses OpenAI
text-embedding-3-large - 1536 dimensions
- Cost tracking: $0.00013 per 1K tokens
- Uses OpenAI
- Service:
app/Services/Memory/EmbeddingService.phpgenerate(string $content): arraygetProvider(): EmbeddingProvider- Logs to
memory_embedding_logs
- Config Update:
config/memory.php- Add embedding provider settings
- 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
Goal: Build semantic connections between memories.
Deliverables:
- Service:
app/Services/Memory/WaypointService.phpbuildWaypointsForMemory(Memory $memory): intbuildAllWaypoints(Tenant $tenant): arrayfindSimilarMemories(Memory $memory, float $threshold): CollectioncosineSimilarity(array $vectorA, array $vectorB): float
- Job:
app/Jobs/Memory/BuildWaypointsJob.php- Batch processes memories
- Creates bidirectional edges
- Chunks by 100 memories
- Command:
php artisan memory:build-waypoints {tenant_id?}- Build waypoints for all or specific tenant
--memory={id}flag for single memory
- 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
Goal: Multi-sector parallel vector search with salience/recency scoring.
Deliverables:
- Service:
app/Services/Memory/MemorySearchService.phpsearch(Tenant $tenant, string $query, array $filters, int $limit): CollectionsearchAllSectors(Tenant $tenant, string $query): CollectiondeduplicateResults(Collection $results): Collection
- DTO:
app/DTOs/Memory/SearchQuery.php- Properties:
query,filters,limit,waypointDepth,sectorBoosts
- Properties:
- DTO:
app/DTOs/Memory/SearchResult.php- Properties:
memory,vectorSimilarity,salience,recency,finalScore,scoreBreakdown
- Properties:
- DTO:
app/DTOs/Memory/ScoringWeights.php- Properties:
similarity,salience,recency,waypoint
- Properties:
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
Goal: Complete hybrid scoring algorithm with waypoint expansion.
Deliverables:
- Service Update:
MemorySearchService.phpcalculateHybridScore(array $result): arrayexpandViaWaypoints(Collection $seedResults, int $maxDepth): CollectionbfsTraversal(array $seeds, int $maxDepth): Collection
- Algorithm: Hybrid scoring formula implementation
- Algorithm: BFS waypoint expansion with decay
- 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
Goal: Automated memory decay with batch processing.
Deliverables:
- Job:
app/Jobs/Memory/DecayMemoriesJob.php- Batch process 1000 memories at a time
- Update MySQL and Typesense
- Log results
- Command:
php artisan memory:decay {tenant_id?}- Manual decay trigger
--forceflag to override last_decayed_at check
- Scheduled Task: Register in
app/Console/Kernel.php- Daily at 02:00 UTC
- Service Update:
MemoryService.phpcalculateDecay(Memory $memory): floatapplyDecay(Memory $memory): void
- 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
Goal: MCP tools/resources and REST API endpoints.
Deliverables:
- Server:
app/Mcp/Servers/MemoryServer.php - Tool:
app/Mcp/Tools/Memory/StoreMemoryTool.php- Parameters:
content,tags[],meta{} - Returns: Memory object with ID
- Parameters:
- Tool:
app/Mcp/Tools/Memory/SearchMemoriesTool.php- Parameters:
query,limit,filters{},waypointDepth - Returns: Array of memories with scores
- Parameters:
- Tool:
app/Mcp/Tools/Memory/RetrieveMemoryTool.php- Parameters:
id - Returns: Memory object (triggers reinforcement)
- Parameters:
- Tool:
app/Mcp/Tools/Memory/ReinforceMemoryTool.php- Parameters:
id - Returns: Updated salience
- Parameters:
- Tool:
app/Mcp/Tools/Memory/PruneMemoriesTool.php- Parameters:
threshold(min salience to keep) - Returns: Count of pruned memories
- Parameters:
- Tool:
app/Mcp/Tools/Memory/AnalyzeMemoryTool.php- Parameters:
id - Returns: Classification, waypoints, access stats
- Parameters:
- Resource:
app/Mcp/Resources/Memory/MemoryResource.php- URI:
memory/{id}
- URI:
- Resource:
app/Mcp/Resources/Memory/MemoriesResource.php- URI:
memories?sector={sector}&limit={limit}
- URI:
- Resource:
app/Mcp/Resources/Memory/WaypointsResource.php- URI:
waypoints/{memory_id}
- URI:
- Resource:
app/Mcp/Resources/Memory/StatsResource.php- URI:
stats(tenant memory statistics)
- URI:
- Controller:
app/Http/Controllers/Api/MemoryController.phpindex()- List memoriesstore()- Create memoryshow()- Get memory (with access tracking)update()- Update memorydestroy()- Delete memorysearch()- Search memories
- Request:
app/Http/Requests/Memory/StoreMemoryRequest.php - Request:
app/Http/Requests/Memory/UpdateMemoryRequest.php - Request:
app/Http/Requests/Memory/SearchMemoryRequest.php - Resource:
app/Http/Resources/MemoryResource.php - Resource:
app/Http/Resources/MemoryCollection.php - 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
Goal: Filament admin interface for memory management.
Deliverables:
- 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
- Pages: CRUD pages (List, Create, Edit, View)
- Resource:
app/Filament/Resources/MemoryWaypointResource.php- Table: Source memory, destination memory, weight
- Filters: Weight range, memory ID
- Widget:
app/Filament/Widgets/MemoryStatsWidget.php- Total memories by tenant
- Average salience by sector
- Total waypoints
- Embedding logs (success/failure ratio)
- Widget:
app/Filament/Widgets/SectorDistributionWidget.php- Pie chart of memories by sector
- 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
Goal: Achieve 80%+ test coverage, complete documentation, final polish.
Deliverables:
- Integration Tests:
tests/Feature/Memory/MemoryWorkflowTest.php- End-to-end: Create → Index → Search → Access → Decay
- Performance Tests:
tests/Performance/MemoryPerformanceTest.php- Search latency benchmarks
- Indexing speed tests
- Decay job performance
- Test Coverage Report: Run
php artisan test --coverage --min=80
- Setup Guide:
docs/memory/setup.md- Installation steps
- Configuration options
- Collection creation
- API Documentation:
docs/memory/api.md- REST API endpoints
- Request/response examples
- MCP Guide:
docs/memory/mcp.md- MCP server configuration
- Tool/resource specifications
- Example workflows
- User Guide:
docs/memory/user-guide.md- Concepts (sectors, waypoints, decay)
- Best practices
- Troubleshooting
- Seeder:
database/seeders/MemorySeeder.php- Creates 100 sample memories per tenant
- Builds waypoints
- Various sectors and salience levels
- Code Review: PSR-12 compliance via Pint
- Optimization: Query optimization, caching
- Bug Fixes: Fix any issues found during testing
- 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
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
app/Models/
├── Memory.php
├── MemoryWaypoint.php
└── MemoryEmbeddingLog.php
app/Services/Memory/
├── MemoryService.php
├── SectorClassificationService.php
├── TypesenseMemoryCollectionManager.php
├── EmbeddingService.php
├── WaypointService.php
├── MemorySearchService.php
└── Embeddings/
├── TypesenseEmbeddingProvider.php
└── OpenAIEmbeddingProvider.php
app/Contracts/
└── EmbeddingProvider.php
app/DTOs/Memory/
├── SearchQuery.php
├── SearchResult.php
└── ScoringWeights.php
app/Jobs/Memory/
├── IndexMemoryJob.php
├── DeleteMemoryFromTypesenseJob.php
├── BuildWaypointsJob.php
└── DecayMemoriesJob.php
app/Events/
├── MemoryCreated.php
├── MemoryUpdated.php
└── MemoryDeleted.php
app/Listeners/
├── IndexMemoryListener.php
├── ReindexMemoryListener.php
└── DeleteMemoryFromTypesenseListener.php
app/Console/Commands/Memory/
├── BuildWaypointsCommand.php
├── DecayMemoriesCommand.php
└── TestEmbeddingsCommand.php
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
app/Http/
├── Controllers/Api/
│ └── MemoryController.php
├── Requests/Memory/
│ ├── StoreMemoryRequest.php
│ ├── UpdateMemoryRequest.php
│ └── SearchMemoryRequest.php
└── Resources/
├── MemoryResource.php
└── MemoryCollection.php
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
config/
└── memory.php
tests/
├── Feature/Memory/
│ ├── MemoryWorkflowTest.php
│ ├── MemoryCrudTest.php
│ ├── MemorySearchTest.php
│ └── MemoryApiTest.php
├── Unit/Memory/
│ ├── SectorClassificationTest.php
│ ├── EmbeddingServiceTest.php
│ ├── WaypointServiceTest.php
│ └── DecayCalculationTest.php
└── Performance/
└── MemoryPerformanceTest.php
docs/memory/
├── setup.md
├── api.md
├── mcp.md
└── user-guide.md
<?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,
];
}
}<?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,
];
}
}<?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),
]);
}
}<?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;
}
}- Overall: 80%+ code coverage
- Services: 90%+ (core business logic)
- Controllers: 80%+ (API endpoints)
- Jobs: 85%+ (background processing)
- Models: 75%+ (methods and scopes)
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)
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);
});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);
});# 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-checkScheduled 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:statsHorizon Dashboard: /horizon
- Monitor
IndexMemoryJobqueue - Monitor
DecayMemoriesJobexecution - 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
| 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 |
| 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 |
| 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 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
- Strict collection naming:
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)
<?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.