22 KiB
05b · Subsystem: Persistent Memory Engine (the MOAT)
Purpose. This is Waggle's durable, per-workspace memory substrate — the thing that makes agents remember across sessions. It is a single SQLite database (one file per "mind") layered into Identity → Awareness → Frames → Knowledge Graph, with hybrid search (vector + keyword fused), relevance scoring, index reconciliation, and a tiered embedding provider chain. Memory is WRITTEN by the CognifyPipeline (one frame per turn + entity/relation extraction) and RECALLED by CombinedRetrieval (workspace + personal + optional KVARK merge). For a frontend rebuild, treat this as a contract: you never touch SQLite directly — you call the sidecar HTTP routes that wrap these classes, and you render the typed shapes documented below.
Grounding: every type, field, table, and constant below is quoted from
packages/hive-mind-core/src/mind/*andpackages/agent/src/{cognify,combined-retrieval,memory-linker}.ts. Where a fact is NOT in those files (e.g. exact HTTP route paths), it is flagged explicitly.
1. Mental model — five layers in one SQLite file
A "mind" is one better-sqlite3 database (with the sqlite-vec extension loaded for the vector table). The schema (schema.ts, SCHEMA_VERSION = '1') defines these layers. Each layer is a TypeScript class wrapping prepared SQL statements — there is no ORM.
| Layer | Class (file) | Table(s) | Cardinality | Role |
|---|---|---|---|---|
| 0 — Identity | IdentityLayer (identity.ts) |
identity |
exactly 1 row (CHECK (id = 1)) |
Who the agent is: name/role/department/personality/capabilities/system_prompt. <500 tokens budget. |
| 1 — Awareness | AwarenessLayer (awareness.ts) |
awareness |
MAX_ITEMS = 10 active |
Short-term working state: active tasks, recent actions, pending items, context flags. Items can expire. |
| 2 — Frames | FrameStore (frames.ts) |
memory_frames (+ memory_frames_fts, memory_frames_vec) |
unbounded | The long-term memory store. I/P/B frame types grouped by session (gop_id). |
| 3 — Knowledge Graph | KnowledgeGraph (knowledge.ts) |
knowledge_entities, knowledge_relations |
unbounded | Entities + typed relations with temporal validity (bitemporal). |
| — Sessions | SessionStore (sessions.ts) |
sessions |
unbounded | Maps gop_id (Group-Of-Pictures id) → project. Parent of frames. |
The "GOP" naming (I-frame / P-frame / B-frame, gop_id, t) is borrowed from video compression: an I-frame is a self-contained keyframe (full state), a P-frame is a delta/update against its base I-frame, a B-frame is a bidirectional cross-reference frame. t is a per-session monotonic sequence number (nextT = MAX(t)+1 for that gop_id).
graph TD
subgraph MindDB["One SQLite file per workspace (a 'mind')"]
ID[identity · 1 row]
AW["awareness · ≤10 items"]
SESS[sessions]
MF[memory_frames]
FTS["memory_frames_fts (FTS5)"]
VEC["memory_frames_vec (vec0 · float[1024])"]
KE[knowledge_entities]
KR[knowledge_relations]
end
SESS -->|gop_id FK| MF
MF -->|rowid = id| FTS
MF -->|rowid = id| VEC
KE -->|source_id / target_id| KR
2. Layer 2 — Frames (FrameStore)
The heart of the store. A MemoryFrame is:
| Field | Type | Notes |
|---|---|---|
id |
number |
PK / rowid; same id used in FTS + vec tables |
frame_type |
'I' | 'P' | 'B' |
FrameType |
gop_id |
string |
session id this frame belongs to |
t |
number |
per-gop_id monotonic sequence (nextT) |
base_frame_id |
number | null |
P/B frames point at their base I-frame |
content |
string |
the actual text |
importance |
'critical' | 'important' | 'normal' | 'temporary' | 'deprecated' |
Importance |
source |
'user_stated' | 'tool_verified' | 'agent_inferred' | 'import' | 'system' | 'personal' | 'workspace' | 'team_sync' |
FrameSource (TS union is wider than the DB CHECK, which only allows the first five) |
access_count |
number |
incremented by touch() on every recall/dup-hit |
created_at |
string |
ISO; harvest path can override to preserve source timestamp |
last_accessed |
string |
ISO; drives temporal scoring |
Write methods
createIFrame(gopId, content, importance='normal', source='user_stated', createdAt?)— dedup-guarded: callsfindDuplicate(content)first; if an identical frame exists ittouch()es it and returns it instead of inserting.createdAtis honored only if it passesisValidIsoTimestamp(strict ISO-8601 withT+ timezone) — used by the harvest path so imported frames keep their original timestamp.createPFrame(gopId, content, baseFrameId, …)— a delta against an I-frame.createBFrame(gopId, content, baseFrameId, referencedFrameIds[])— stores{description, references}as JSON incontent.
Every create also runs indexFts(frame) to mirror content into the FTS5 table. Vector indexing is NOT done here — it happens in HybridSearch.indexFrame(), called by CognifyPipeline (see §6).
Dedup (findDuplicate) — important quirks for the frontend
- Hash =
SHA-256( stripHmPrefix(content).trim() ). stripHmPrefixremoves a leading[hm session:… src:… event:…]provenance prefix so two captures of the same turn from different sources collapse into one frame.- Only the last 500 frames are scanned (cost bound). Dedup is best-effort beyond the recency window.
Read / list methods
| Method | Returns |
|---|---|
getById(id) |
one frame |
getLatestIFrame(gopId) |
newest I-frame in a session |
getPFramesSinceLastI(gopId) |
P-frames after the latest I-frame |
getGopFrames(gopId) |
all frames in a session, t ASC |
reconstructState(gopId) |
{ iframe, pframes } — current state = latest I + its P-deltas |
getRecent(limit=50) / list({limit}) |
newest frames, id DESC |
getRecentFiltered(limit, since?, until?) |
F20: date-bounded recent frames |
getStats() |
{ total, byType, byImportance } |
update(id, content, importance?) |
updates main + FTS + clears vec entry |
delete(id) |
removes from main + FTS + vec + kg_entity_frames and nulls referring base_frame_id |
Compaction (compact(maxTempAgeDays=30, maxDeprecatedAgeDays=90))
Maintenance op: deletes old temporary and deprecated frames, and for any gop_id with >10 P-frames merges all-but-the-5-most-recent P-frames into their I-frame (joined with \n---\n). Returns { temporaryPruned, deprecatedPruned, pframesMerged }.
3. Hybrid Search (HybridSearch) — vector + keyword fusion
search(query, options) runs keyword and vector searches in parallel, fuses with Reciprocal Rank Fusion (RRF), then multiplies by a relevance score.
SearchOptions:
| Field | Type | Default |
|---|---|---|
limit |
number |
20 |
gopId |
string? |
(scope to one session) |
profile |
'balanced' | 'recent' | 'important' | 'connected' |
'balanced' |
context |
ScoringContext |
{} |
since / until |
string? (ISO) |
temporal filter on created_at |
SearchResult:
| Field | Type | Meaning |
|---|---|---|
frame |
MemoryFrame |
the hit |
rrfScore |
number |
fused rank score |
relevanceScore |
number |
from computeRelevance |
finalScore |
number |
rrfScore * relevanceScore — the sort key |
Fusion algorithm (the actual constants)
- Run
keywordSearch(query, limit*2, gopId)andvectorSearch(query, limit*2, gopId)in parallel; each returns an orderednumber[]of frame ids. - RRF with
RRF_K = 60: each id accrues1 / (RRF_K + rank)from each list. - Fetch the union of frame ids (applying
since/untilfilters here), computerelevanceScore, setfinalScore = rrfScore * relevanceScore, sort desc, slice tolimit.
Keyword path (FTS5) — recall tuning
- Query is tokenized, punctuation stripped, a built-in stop-word list removed, tokens shorter than 3 chars dropped, then OR-joined (
"foo" OR "bar") for recall. FTS5ORDER BY rank. - On FTS5 parse error it falls back to
likeFallbackSearch— OR-edLIKE … ESCAPE '\'overcontent(parameterized, metachars escaped). This guarantees a query never returns a false "no memory found" because the user typed an FTS5 operator.
Vector path (sqlite-vec vec0)
vectorSearchembeds the query (embedder.embed), converts theFloat32Arrayto a blob, and does aMATCH ? AND k = ?KNN query onmemory_frames_vec(ORDER BY distance).- When
gopIdis set it over-fetches (k = limit*3) then filters by session. - The vec table is
float[1024]— embeddings MUST be 1024-dim (see §7). - All vec operations are wrapped in
try/catchreturning[]— if thesqlite-vecextension or table is absent, search silently degrades to keyword-only.
Indexing (called by cognify, not by FrameStore)
indexFrame(frameId, content)— embed +INSERT INTO memory_frames_vec. (rowid is inlined as a SQL literal because vec0 doesn't accept a parameterized rowid.)indexFramesBatch(frames[])— batch embed + transactional insert.
4. Relevance scoring (scoring.ts)
computeRelevance(frame, weights, context) = weighted sum of four sub-scores. Profiles pick the weights:
| Profile | temporal | popularity | contextual | importance |
|---|---|---|---|---|
balanced |
0.4 | 0.2 | 0.2 | 0.2 |
recent |
0.6 | 0.1 | 0.2 | 0.1 |
important |
0.1 | 0.1 | 0.2 | 0.6 |
connected |
0.1 | 0.1 | 0.6 | 0.2 |
Sub-scores:
- temporal —
1.0iflast_accessedwithinRECENCY_BOOST_DAYS = 7, else exponential decay withHALF_LIFE_DAYS = 30. - popularity —
1 + log10(1 + access_count) * 0.1. - contextual — graph proximity: distance 0→1.0, 1→0.7, 2→0.4, 3→0.2, else 0 (needs
context.graphDistances, aMap<frameId, BFS-distance>). - importance —
critical 2.0 / important 1.5 / normal 1.0 / temporary 0.7 / deprecated 0.3.
ScoringContext = { recentEntityIds?: number[]; graphDistances?: Map<number, number> }.
5. Knowledge Graph (KnowledgeGraph)
Bitemporal entity-relation store. Entity and Relation both carry valid_from / valid_to (null = currently valid) plus recorded_at.
Entity: { id, entity_type, name, properties(JSON string), valid_from, valid_to, recorded_at }
Relation: { id, source_id, target_id, relation_type, confidence(REAL), properties(JSON), valid_from, valid_to, recorded_at }
Key methods:
| Method | Purpose |
|---|---|
createEntity(type, name, props, temporal?) |
validates against optional schema, inserts |
getEntitiesByType(type, limit=500) / getEntities(limit, offset) |
active entities (valid_to IS NULL) |
searchEntities(query, limit=100) |
name LIKE (escaped) |
getEntityTypeCounts() / getEntityCount() |
dashboard counts without full fetch |
getEntitiesValidAt(isoTime) |
time-travel: entities valid at a past instant |
createRelation(src, tgt, type, confidence=1.0, props) |
validated insert |
getRelationsFrom(id, type?) / getRelationsTo(id, type?) |
adjacency |
retireEntity(id) / retireRelation(id) |
sets valid_to = now (soft-delete, never hard delete) |
traverse(startId, relationType, maxDepth) |
BFS returning reached entities |
bfsDistances(startId, maxDepth) |
Map<entityId, distance> — feeds the contextual score |
Optional setValidationSchema(ValidationSchema) enforces required properties per entity-type and an allowed-relations list (throws on violation). Without a schema, all writes pass.
6. WRITE path — CognifyPipeline (cognify.ts)
cognify(content, importance='normal', gopId?, turnId?) is the canonical "remember this turn" call. Steps:
- Ensure session —
sessions.ensureActive()(transaction-wrapped to avoid the twin-session race). - Save frame — if a latest I-frame exists for the session →
createPFrame(delta), elsecreateIFrame(keyframe). Dedup applies inside the FrameStore. - Extract entities —
extractEntities(content.slice(0, 10_000))(fromentity-extractor.ts). - Upsert entities into the KG (skip if same type+name exists; cached per-type to avoid N queries).
- Co-occurrence relations —
co_occurs_with(confidence 0.8) between every entity pair found in the same text. - Semantic relations —
extractRelations→ typed relations (led_by,reports_to,depends_on, …) matched back to KG entities. - Vector index —
search.indexFrame(frame.id, content). - Optional linking — if
enableLinking,MemoryLinker.findRelated(content)returns related frames (self excluded).
CognifyResult = { frameId, entitiesExtracted, relationsCreated, relatedFrames? }.
Other entry points: cognifyFrame(frameId) (re-process one imported frame — used post-harvest) and cognifyBatch(frameIds[]) (sequential, so each frame's new entities can link to the next).
MemoryLinker (memory-linker.ts)
Thin wrapper over HybridSearch.search. findRelated(content, limit=5) returns MemoryLink[] = { frameId, content, score }, filtered by a threshold (default 0.1 on finalScore).
flowchart TD
A["agent turn / harvest / MCP save_memory"] --> B["CognifyPipeline.cognify(content)"]
B --> C["SessionStore.ensureActive() → gop_id"]
B --> D{latest I-frame exists?}
D -- no --> E["FrameStore.createIFrame (dedup-guarded)"]
D -- yes --> F["FrameStore.createPFrame"]
E --> G["indexFts (FTS5)"]
F --> G
B --> H["extractEntities → KnowledgeGraph.upsert"]
H --> I["co_occurs_with + semantic relations"]
B --> J["HybridSearch.indexFrame → memory_frames_vec (1024-dim)"]
B --> K["MemoryLinker.findRelated (optional)"]
7. Embeddings — provider chain (embedding-provider.ts)
createEmbeddingProvider(config?) returns an EmbeddingProviderInstance (implements the Embedder interface: embed, embedBatch, dimensions). Default targetDimensions = 1024 — matches the vec0 float[1024] table.
Auto fallback chain (provider: 'auto'): inprocess → ollama → voyage → openai → mock. Each is probed with a 1024-dim test embedding; the first that succeeds becomes active.
Provider (EmbeddingProviderType) |
Default model | Needs |
|---|---|---|
inprocess |
Xenova/all-MiniLM-L6-v2 (Transformers.js) |
nothing — fully local |
ollama |
nomic-embed-text |
local Ollama server |
voyage |
voyage-3-lite |
voyage.apiKey (from Vault) |
openai |
text-embedding-3-small |
openai.apiKey |
litellm |
text-embedding |
litellm.url |
mock |
deterministic-mock |
always available; semantically meaningless (last resort) |
Tier gating: provider availability is gated by TIER_CAPABILITIES[tier].embeddingProviders; embeddingQuotaPerMonth is enforced per user_id per month in the embedding_usage table (-1 = unlimited). Quota throws EmbeddingQuotaExceededError (carries tier/quota/current/upgradeUrl); over-tier provider request throws TierError. WAGGLE_EVAL_MODE=1 disables all gating (eval harness only). getStatus() / getQuotaStatus() / reprobe() expose state for a settings UI.
Frontend note: if the active provider is
mock, surface a "semantic search degraded" warning —getStatus().activeProvider === 'mock'andlastErrortell you. Mismatched embedder dimensions would break the vec table, so the provider hard-asserts 1024 on probe.
8. RECALL path — CombinedRetrieval (combined-retrieval.ts)
The merge engine the agent calls to answer "what do I know about X". Merges workspace + personal memory and optionally KVARK enterprise search. Pure data in / out (no formatting).
search(query, opts) → CombinedRetrievalResult:
| Field | Type |
|---|---|
query |
string |
workspaceResults / personalResults / kvarkResults |
CombinedResult[] |
kvarkAvailable |
boolean |
kvarkSkipped |
boolean (available but coverage was sufficient) |
kvarkError? |
string |
hasConflict |
boolean |
conflictNote? |
string |
CombinedResult = { content, source: 'workspace'|'personal'|'kvark', attribution, score, metadata } where attribution is a human tag like [workspace memory] / [personal memory], and metadata carries frameId/frameType/importance (memory) or documentId/documentType (KVARK).
CombinedSearchOptions = { limit=10, profile='balanced', scope: 'all'|'personal'|'workspace', turnId? }.
KVARK gating logic (shouldQueryKvark): KVARK is queried only when a client exists, scope==='all', AND local coverage is insufficient — hasSufficientLocalCoverage = fewer than LOCAL_COVERAGE_MIN_COUNT = 3 results with score ≥ LOCAL_COVERAGE_SCORE_THRESHOLD = 0.7. KVARK failures degrade gracefully (local results preserved, kvarkError set).
Conflict detection (detectConflict): if both workspace and KVARK have strong results (score ≥ CONFLICT_SCORE_THRESHOLD = 0.6) and their top-3 texts disagree on status polarity (POSITIVE_STATUS words like approved/selected vs NEGATIVE_STATUS like rejected/cancelled), it returns a human-readable conflictNote for the UI to surface ("these sources may be out of sync").
flowchart TD
Q["CombinedRetrieval.search(query, scope)"] --> WS["searchWorkspace → HybridSearch"]
Q --> PS["searchPersonal → HybridSearch"]
WS --> LC{"sufficient local coverage?\n≥3 results @ score ≥0.7"}
PS --> LC
LC -- yes / scope≠all / no client --> OUT["return local results, kvarkSkipped"]
LC -- no --> KV["searchKvark (tier-gated, graceful fail)"]
KV --> CF["detectConflict(workspace, kvark)"]
CF --> OUT2["return merged + hasConflict/conflictNote"]
9. Reconciliation & integrity (reconcile.ts)
A crash between frame insert and FTS/vec indexing leaves frames that exist but aren't searchable. The reconcile functions repair this (idempotent, cron-friendly):
reconcileFtsIndex(db)— re-index frames missing from FTS5 (no embedder needed).reconcileVecIndex(db, embedder)— embed + re-index frames missing from the vec table (batches of 50).cleanOrphanFts(db)/cleanOrphanVectors(db)— drop FTS/vec rows whose frame was deleted.reconcileIndexes(db, embedder?)— runs all of the above; FTS-only if no embedder. Returns{ ftsFixed, vecFixed }.
10. Sessions (SessionStore)
Session = { id, gop_id, project_id, status: 'active'|'closed'|'archived', started_at, ended_at, summary }. gop_id format: session:<ISO>:<rand6>.
| Method | Purpose |
|---|---|
create(projectId?) |
new timestamped session |
ensureActive(projectId?) |
transaction-wrapped — returns existing active session or creates one (prevents twin-session race; used by cognify) |
ensure(gopId, …) |
idempotent named session (e.g. a stable harvest parent) |
close(gopId, summary?) / archive(gopId) |
lifecycle |
getByProject / getActive / getByGopId |
queries |
11. Schema reference (DDL, verbatim from schema.ts)
SCHEMA_VERSION = '1'. Tables relevant to this subsystem:
| Table | Key columns / constraints |
|---|---|
identity |
id CHECK (id = 1) (single row), name/role/department/personality/capabilities/system_prompt, created_at, updated_at |
awareness |
category CHECK IN ('task','action','pending','flag'), content, priority, metadata (JSON), created_at, expires_at |
sessions |
gop_id UNIQUE, project_id, status CHECK IN ('active','closed','archived'), started_at, ended_at, summary; index (project_id, started_at) |
memory_frames |
frame_type CHECK IN ('I','P','B'), gop_id (FK→sessions), t, base_frame_id (self-FK), content, importance CHECK IN (critical/important/normal/temporary/deprecated), source CHECK IN (user_stated/tool_verified/agent_inferred/import/system), access_count, created_at, last_accessed; indexes on (gop_id,t),(frame_type,gop_id),(base_frame_id) |
memory_frames_fts |
CREATE VIRTUAL TABLE … USING fts5(content, content_rowid='id', tokenize='porter unicode61') |
memory_frames_vec |
CREATE VIRTUAL TABLE … USING vec0(embedding float[1024]) (separate VEC_TABLE_SQL, requires sqlite-vec) |
knowledge_entities |
entity_type, name, properties(JSON), valid_from, valid_to, recorded_at; indexes on type and name |
knowledge_relations |
source_id/target_id (FK→entities), relation_type, confidence(REAL), properties(JSON), valid_from, valid_to, recorded_at; indexes on (source_id,relation_type),(target_id,relation_type) |
embedding_usage |
(in embedding-provider.ts) user_id, year_month, count, updated_at; UNIQUE(user_id, year_month) — monthly quota counter |
harvest_sources |
source UNIQUE, display_name, source_path, last_synced_at, items_imported, frames_created, auto_sync, sync_interval_hours, last_content_hash, created_at |
Note: the TS
FrameSourceunion (frames.ts) includes'personal' | 'workspace' | 'team_sync'which the DBCHECKdoes not list — those extra sources are application-level and not written through the constrained column path.
12. HTTP surface (how the frontend reaches this)
These engine classes are server-side only; the frontend talks to the Fastify sidecar and the hive-mind MCP server, not to SQLite. The exact route paths are defined in packages/server/src (outside this section's read scope) — do not invent them. What this subsystem guarantees, and the MCP tool names that wrap it (from the hive-mind MCP server, visible in this environment), are:
| MCP tool | Wraps |
|---|---|
recall_memory |
CombinedRetrieval.search / HybridSearch.search |
save_memory |
CognifyPipeline.cognify |
save_entity / create_relation / search_entities |
KnowledgeGraph |
get_identity / set_identity |
IdentityLayer |
get_awareness / set_awareness / clear_awareness |
AwarenessLayer |
cleanup_frames / cleanup_entities |
FrameStore.compact / KG retire |
create_workspace / list_workspaces |
per-mind DB lifecycle |
harvest_import / harvest_sources / ingest_source |
harvest → cognifyFrame/cognifyBatch |
For the precise sidecar REST routes (method + path), consult the server-routes section of this backend map — they are the authoritative contract the Lovable frontend will call.