OpenBrain RFC — Semantic Memory for the Core Ecosystem
api.lthn.ai/v1/brain/*— Semantic memory storage, recall, and discovery. This document is the authoritative contract. An agent should be able to implement any feature from this document alone.
Status: Draft specification, implementation baseline verified in-repo on 2026-03-31
Module: Core\Mod\Agentic\Services\BrainService
Depends: MariaDB (source of truth), Qdrant (vector index), Ollama (embeddings), Elasticsearch (full-text + tag discovery)
Implementation snapshot (2026-03-31): async indexing via
EmbedMemory, scope-aware recall, agent/session/shared memory spaces, agent context boot, recall feedback, Elasticsearch discovery, and thebrain:reindex/brain:clean/brain:prunemaintenance commands are implemented and covered by the local test suite.
1. Architecture
┌─────────────┐ ┌──────────────┐ ┌──────────┐
│ API Client │────▶│ BrainService │────▶│ MariaDB │ ← source of truth
│ (core-agent) │ │ │ │ (text + │
└─────────────┘ │ │ │ metadata)│
│ │ └──────────┘
│ │ ┌──────────┐
│ │────▶│ Qdrant │ ← vector index only
│ │ │ (embeddings│
│ │ │ + payload)│
│ │ └──────────┘
│ │ ┌──────────┐
│ │────▶│ Ollama │ ← embedding generation
│ │ │ (embeddinggemma)│
│ │ └──────────┘
│ │ ┌──────────┐
│ │────▶│ Elastic │ ← full-text search
│ │ │ (tag discovery│
│ │ │ + scoping)│
└──────────────┘ └──────────┘
1.1 Data Flow
- Remember: API → MariaDB (immediate) → Queue Job → Ollama embed → Qdrant upsert + Elasticsearch index
- Recall: API → Ollama embed query → Qdrant search (vector IDs) → MariaDB hydrate (full text)
- Discover: API → Elasticsearch (full-text search, tag aggregation, scope listing)
- Re-index: Artisan command → MariaDB scan → Ollama embed → Qdrant upsert + Elasticsearch index
1.2 Principle: MariaDB Is Truth
Qdrant and Elasticsearch are derived indexes. If either is lost, they can be rebuilt from MariaDB. The remember endpoint stores text in MariaDB FIRST, then queues async indexing. This means:
- API returns immediately (no waiting for Ollama/Qdrant)
- Embedding failures don't lose data
- Re-indexing is a safe operation (rebuild from source)
- Text cleanup happens in MariaDB, then re-index propagates it
2. Scoping Model
Memories are scoped by {org}/{project} to prevent cross-contamination between ecosystems.
2.1 Schema
workspace_id INT — tenant isolation (existing)
org VARCHAR(100) — organisation namespace: "core", "lthn", "ofm", "host-uk"
project VARCHAR(255) — project within org: "go-io", "agent", "lthn.ai", "ofm.bot"
2.2 Scope Hierarchy
workspace_id = 1 ← tenant boundary (hard)
org = "core" ← ecosystem boundary
project = "go-io" ← repo-level memories
project = "agent" ← repo-level memories
project = "go" ← framework-level memories
org = "lthn" ← product boundary
project = "lthn.ai" ← app-level memories
project = "api" ← service-level memories
org = "ofm" ← client boundary
project = "ofm.bot" ← app-level memories
2.3 Query Scoping
# Recall within a project
POST /v1/brain/recall
{ "query": "...", "org": "core", "project": "go-io" }
# Recall across an org (all core repos)
POST /v1/brain/recall
{ "query": "...", "org": "core" }
# Recall everything (workspace-scoped only)
POST /v1/brain/recall
{ "query": "..." }
2.4 Qdrant Filter Extension
The buildQdrantFilter method adds:
if (isset($criteria['org'])) {
$must[] = ['key' => 'org', 'match' => ['value' => $criteria['org']]];
}
The org field is stored in both MariaDB and the Qdrant payload.
3. Async Embedding (Delayed Indexing)
3.1 Original Problem
remember() calls $this->embed() synchronously. Ollama embedding takes 200-500ms. The API blocks until Qdrant upsert completes. This is slow and fragile — if Ollama is down, the entire remember fails.
3.2 Solution: Queue Job
// BrainService::remember()
public function remember(array $attributes): BrainMemory
{
// Step 1: Store in MariaDB immediately
$memory = BrainMemory::create($attributes);
// Step 2: Dispatch async indexing
EmbedMemory::dispatch($memory->id);
// Step 3: Handle supersedes
if ($memory->supersedes_id) {
BrainMemory::where('id', $memory->supersedes_id)->delete();
DeleteFromIndex::dispatch($memory->supersedes_id);
}
return $memory;
}
3.3 EmbedMemory Job
class EmbedMemory implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(
public string $memoryId,
) {}
public function handle(BrainService $brain): void
{
$memory = BrainMemory::find($this->memoryId);
if (! $memory) return;
// Generate embedding
$vector = $brain->embed($memory->content);
// Upsert to Qdrant
$brain->qdrantUpsert([
$brain->buildQdrantPayload($memory->id, [
'workspace_id' => $memory->workspace_id,
'org' => $memory->org,
'project' => $memory->project,
'agent_id' => $memory->agent_id,
'type' => $memory->type,
'tags' => $memory->tags ?? [],
'confidence' => $memory->confidence,
'source' => $memory->source ?? 'manual',
'content' => $memory->content, // store text in payload for re-ranking
'created_at' => $memory->created_at->toIso8601String(),
]) + ['vector' => $vector],
]);
// Index in Elasticsearch
$brain->elasticIndex($memory);
// Mark as indexed
$memory->update(['indexed_at' => now()]);
}
}
3.4 Memory States
| State | indexed_at |
Meaning |
|---|---|---|
| Pending | null |
Stored in MariaDB, not yet in Qdrant/ES |
| Indexed | timestamp | In all three stores |
| Stale | timestamp < updated_at |
MariaDB updated, needs re-indexing |
4. Re-indexing
4.1 Artisan Command
# Re-index all memories
php artisan brain:reindex
# Re-index a specific org
php artisan brain:reindex --org=core
# Re-index a specific project
php artisan brain:reindex --org=core --project=go-io
# Re-index only stale (updated since last index)
php artisan brain:reindex --stale
# Dry run — show what would be re-indexed
php artisan brain:reindex --dry-run
4.2 Process
- Query MariaDB for matching memories
- For each memory (batched):
a. Generate embedding via Ollama
b. Upsert to Qdrant (vector + payload)
c. Index to Elasticsearch (text + metadata)
d. Update
indexed_attimestamp - Report: total processed, successes, failures, duration
4.3 Text Refinement
Before re-indexing, the text in MariaDB can be cleaned:
# Strip PII from all memories
php artisan brain:clean --strip-pii
# Normalise whitespace and formatting
php artisan brain:clean --normalise
# Remove memories below confidence threshold
php artisan brain:prune --min-confidence=0.3
# Remove duplicate content (keep highest confidence)
php artisan brain:prune --deduplicate
After cleaning, brain:reindex --stale picks up the changes.
5. Elasticsearch Integration
5.1 Purpose
Qdrant does semantic search (find similar meanings). Elasticsearch does:
- Full-text search — exact keyword matching, fuzzy search
- Tag discovery — aggregate tags across memories to find common topics
- Scope listing — list all orgs/projects with memory counts
- Faceted search — filter by type + org + project + tags simultaneously
5.2 Index Schema
{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"workspace_id": { "type": "integer" },
"org": { "type": "keyword" },
"project": { "type": "keyword" },
"type": { "type": "keyword" },
"agent_id": { "type": "keyword" },
"tags": { "type": "keyword" },
"confidence": { "type": "float" },
"content": { "type": "text", "analyzer": "english" },
"source": { "type": "keyword" },
"created_at": { "type": "date" }
}
}
}
5.3 API Endpoints
# Full-text search (keyword matching, not semantic)
GET /v1/brain/search?q=kubernetes+deploy&org=core
# Discover tags for a scope
GET /v1/brain/tags?org=core&project=go-io
→ { "tags": [{"name": "architecture", "count": 15}, {"name": "testing", "count": 12}, ...] }
# List scopes (orgs and projects with counts)
GET /v1/brain/scopes
→ { "scopes": [
{"org": "core", "projects": [{"name": "go-io", "count": 45}, {"name": "agent", "count": 120}]},
{"org": "lthn", "projects": [{"name": "lthn.ai", "count": 30}]},
{"org": "ofm", "projects": [{"name": "ofm.bot", "count": 8}]}
]}
# Combined: semantic + keyword boost
POST /v1/brain/recall
{ "query": "...", "org": "core", "keywords": ["Service", "Register"], "boost_keywords": 0.3 }
5.4 Tag Discovery for Agent Dispatch
When dispatching an agent to a repo, the dispatcher can query:
GET /v1/brain/tags?org=core&project=go-io
This returns the most common topics for that repo. The dispatcher includes these in the agent's context: "this repo is primarily about: file I/O, medium abstraction, node storage, workspace management."
6. Memory Lifecycle — Optimisation Over Time
Memories accumulate. Without lifecycle management, the vector space fills with stale, redundant, and low-value data. Quality degrades as noise increases.
6.1 Memory States
created → pending_embed → indexed → stale → archived → deleted
↑
updated (content changed in MariaDB)
| State | Description |
|---|---|
created |
In MariaDB, not yet embedded |
pending_embed |
Queue job dispatched |
indexed |
In MariaDB + Qdrant + Elasticsearch |
stale |
Content updated in MariaDB, index out of date |
archived |
Soft-removed from active recall, kept for audit |
deleted |
Hard-deleted from all stores |
6.2 Automatic Consolidation
Over time, an agent produces many memories about the same topic. Consolidation merges them:
# Consolidate memories with >0.95 cosine similarity within a scope
php artisan brain:consolidate --org=core --project=go-io --threshold=0.95
# Dry run — show what would merge
php artisan brain:consolidate --org=core --dry-run
Consolidation process:
- Query Qdrant for vectors within a scope
- Find clusters with cosine similarity > threshold
- For each cluster: pick the highest-confidence memory as primary
- Merge content from others into primary (append unique information)
- Re-embed the merged content
- Archive the merged-from memories (keep for audit trail)
- The
supersedes_idchain tracks lineage
6.3 Decay and Pruning
Memories lose relevance over time. Decay is configurable per type:
// config/brain.php
'decay' => [
'observation' => ['half_life_days' => 30], // short-lived
'context' => ['half_life_days' => 60], // medium
'decision' => ['half_life_days' => 180], // long-lived
'convention' => ['half_life_days' => null], // never decays
'architecture' => ['half_life_days' => null], // never decays
'procedure' => ['half_life_days' => 365], // yearly review
],
# Prune memories below effective confidence threshold
php artisan brain:prune --min-effective-confidence=0.2
# Show what would be pruned
php artisan brain:prune --dry-run
Effective confidence = base_confidence * decay_factor(age, half_life).
6.4 Quality Scoring
Each recall interaction provides implicit quality feedback:
- Memory that appears in results AND the agent uses it → boost confidence
- Memory that appears in results AND the agent ignores it → decay confidence
- Memory that never appears in results → candidate for archival
// Track usage in recall response
POST /v1/brain/recall/{recall_id}/feedback
{
"used": ["uuid-1", "uuid-3"], // agent used these
"ignored": ["uuid-2", "uuid-4"] // agent saw but didn't use
}
This feedback loop gradually separates valuable memories from noise without manual curation.
7. Agent Memory Spaces
Agents need their own memory area separate from project memories. An agent's memories are about how it works, what it's learned about the user, and its own operational state.
7.1 Memory Categories
| Category | Scope | Owner | Example |
|---|---|---|---|
| Project | {org}/{project} |
Shared | "go-io uses the Medium interface pattern" |
| Agent | {agent_id} |
Individual agent | "User prefers terse responses" |
| Session | {session_id} |
Ephemeral | "Currently debugging the dispatch queue" |
| Shared | {org} |
Cross-project | "Core uses UK English in all comments" |
7.2 Agent Memory Schema
agent_id VARCHAR(100) — "cladius", "charon", "codex"
memory_scope ENUM('project', 'agent', 'session', 'shared')
7.3 Agent Memory API
# Store an agent-scoped memory
POST /v1/brain/remember
{
"content": "User has ADHD — prefers direct answers, no preamble",
"type": "observation",
"memory_scope": "agent",
"agent_id": "cladius",
"confidence": 0.95
}
# Recall agent memories (includes agent + shared + project)
POST /v1/brain/recall
{
"query": "user preferences",
"agent_id": "cladius",
"include_scopes": ["agent", "shared"]
}
# Agent boot — load all agent memories for context
GET /v1/brain/agent/{agent_id}/context
→ Returns agent-scoped memories sorted by confidence, paginated
7.4 Memory Priority in Recall
When an agent recalls, memories are ranked by:
- Relevance (cosine similarity from Qdrant)
- Scope priority: agent > project > shared > session
- Confidence (base × decay factor)
- Recency (tiebreaker)
Agent memories outrank project memories at equal relevance because they represent learned behaviour specific to that agent's relationship with the user.
7.5 Agent Context Boot
When an agent starts a session, it loads its context:
// Automatic on session start
$context = $brain->agentBoot('cladius', [
'org' => 'core',
'project' => 'agent',
'max_memories' => 50,
]);
// Returns:
// - Agent memories (preferences, corrections, patterns)
// - Shared org memories (conventions, architecture)
// - Recent project memories (what changed recently)
// - Active session memories (if resuming)
This replaces manually loading context — the brain provides it based on who the agent is and what they're working on.
8. Inter-Module Communication
6.1 Events
Modules communicate via Laravel events (same pattern as CorePHP lifecycle):
// When a memory is stored
event(new MemoryStored($memory));
// When a memory is recalled (for analytics)
event(new MemoryRecalled($query, $results, $duration));
// When re-indexing completes
event(new ReindexCompleted($org, $project, $count, $duration));
// When an agent session starts (Agentic module)
event(new AgentSessionStarted($agentId, $org, $project));
6.2 Module Listeners
// Lem module listens for memory events to update training data
MemoryStored::class => [UpdateTrainingData::class]
// Uptelligence module listens for recall latency
MemoryRecalled::class => [TrackRecallLatency::class]
// Mcp module listens for session events
AgentSessionStarted::class => [RegisterMcpSession::class]
7. API Contract
7.1 Remember (Store)
POST /v1/brain/remember
Authorization: Bearer {api_key}
{
"content": "The Service pattern uses Register() factory functions",
"type": "convention",
"org": "core",
"project": "go",
"agent_id": "cladius",
"tags": ["service", "pattern", "register"],
"confidence": 0.9,
"source": "session:2026-03-27"
}
→ 201 Created
{
"id": "uuid",
"type": "convention",
"org": "core",
"project": "go",
"indexed": false,
"created_at": "2026-03-27T14:00:00Z"
}
Response returns immediately. indexed: false indicates the embedding is queued.
7.2 Recall (Semantic Search)
POST /v1/brain/recall
Authorization: Bearer {api_key}
{
"query": "how do services register with Core?",
"top_k": 10,
"org": "core",
"project": "go",
"min_confidence": 0.5
}
→ 200 OK
{
"memories": [
{
"id": "uuid",
"content": "The Service pattern uses Register() factory functions",
"type": "convention",
"org": "core",
"project": "go",
"score": 0.92,
"confidence": 0.9,
"created_at": "2026-03-27T14:00:00Z"
}
]
}
7.3 Search (Full-text)
GET /v1/brain/search?q=Register+factory&org=core
Authorization: Bearer {api_key}
→ 200 OK
{
"hits": [...],
"total": 15,
"took_ms": 12
}
7.4 Tags (Discovery)
GET /v1/brain/tags?org=core&project=go-io
Authorization: Bearer {api_key}
→ 200 OK
{
"tags": [
{"name": "medium", "count": 23},
{"name": "filesystem", "count": 18},
{"name": "node", "count": 12}
]
}
7.5 Scopes (Listing)
GET /v1/brain/scopes
Authorization: Bearer {api_key}
→ 200 OK
{
"scopes": [
{
"org": "core",
"count": 450,
"projects": [
{"name": "go", "count": 120},
{"name": "agent", "count": 85},
{"name": "go-io", "count": 45}
]
}
]
}
8. Migration
8.1 Database Migration
Schema::table('brain_memories', function (Blueprint $table) {
$table->string('org', 100)->nullable()->after('workspace_id')->index();
$table->timestamp('indexed_at')->nullable()->after('updated_at');
$table->index(['org', 'project']);
});
8.2 Backfill Existing Data
# Infer org from project field for existing memories
php artisan brain:backfill-org
# Re-index all with new payload schema (includes org + content in payload)
php artisan brain:reindex
8.3 Elasticsearch Setup
# Create the index
php artisan brain:elastic-setup
# Index existing memories
php artisan brain:reindex --elastic-only
9. Configuration
// config/brain.php
return [
'ollama_url' => env('OLLAMA_URL', 'http://localhost:11434'),
'qdrant_url' => env('QDRANT_URL', 'http://localhost:6334'),
'elastic_url' => env('ELASTICSEARCH_URL', 'http://localhost:9200'),
'collection' => env('BRAIN_COLLECTION', 'openbrain'),
'elastic_index' => env('BRAIN_ELASTIC_INDEX', 'openbrain'),
'embedding_model' => env('BRAIN_EMBEDDING_MODEL', 'embeddinggemma'),
'vector_dimension' => 768,
'async_embedding' => env('BRAIN_ASYNC_EMBED', true),
'verify_ssl' => env('BRAIN_VERIFY_SSL', true),
];
10. Testing
10.1 Unit Tests
TestBrainService_Remember_Good — stores in MariaDB, dispatches job
TestBrainService_Remember_Bad — validation failure
TestBrainService_Remember_Ugly — Ollama down (async = still succeeds)
TestBrainService_Recall_Good — returns ranked memories
TestBrainService_Recall_Bad — empty results
TestBrainService_Recall_Good_ScopedByOrg — filters by org
TestBrainService_Recall_Good_ScopedByProject — filters by project
TestEmbedMemory_Good — job embeds and upserts
TestEmbedMemory_Bad — Ollama failure, retries
TestEmbedMemory_Ugly — memory deleted before job runs
TestBrainService_Search_Good — Elasticsearch returns hits
TestBrainService_Tags_Good — aggregation returns top tags
TestBrainService_Scopes_Good — lists orgs with counts
10.2 Integration Tests
TestBrainApi_Remember_Recall_RoundTrip — store then find
TestBrainApi_ScopeIsolation — org A can't see org B memories
TestBrainApi_AsyncEmbedding — remember returns fast, recall works after job
TestBrainApi_Reindex — clean + reindex produces same results
Changelog
- 2026-03-27: Initial draft — async embedding, scoping, Elasticsearch, re-indexing