Skip to main content

RFC reference

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...

doc/RFC-OPENBRAIN.md

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 the brain:reindex / brain:clean / brain:prune maintenance 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

  1. Query MariaDB for matching memories
  2. For each memory (batched): a. Generate embedding via Ollama b. Upsert to Qdrant (vector + payload) c. Index to Elasticsearch (text + metadata) d. Update indexed_at timestamp
  3. 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:

  1. Query Qdrant for vectors within a scope
  2. Find clusters with cosine similarity > threshold
  3. For each cluster: pick the highest-confidence memory as primary
  4. Merge content from others into primary (append unique information)
  5. Re-embed the merged content
  6. Archive the merged-from memories (keep for audit trail)
  7. The supersedes_id chain 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:

  1. Relevance (cosine similarity from Qdrant)
  2. Scope priority: agent > project > shared > session
  3. Confidence (base × decay factor)
  4. 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