Files
waggle-os/docs/backend-map/sections/05b-subsystem-memory.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

338 lines
22 KiB
Markdown

# 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/*` and `packages/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`).
```mermaid
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**: calls `findDuplicate(content)` first; if an identical frame exists it `touch()`es it and returns it instead of inserting. `createdAt` is honored only if it passes `isValidIsoTimestamp` (strict ISO-8601 with `T` + 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 in `content`.
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() )`.
- `stripHmPrefix` removes 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)
1. Run `keywordSearch(query, limit*2, gopId)` and `vectorSearch(query, limit*2, gopId)` in parallel; each returns an ordered `number[]` of frame ids.
2. RRF with `RRF_K = 60`: each id accrues `1 / (RRF_K + rank)` from each list.
3. Fetch the union of frame ids (applying `since`/`until` filters here), compute `relevanceScore`, set `finalScore = rrfScore * relevanceScore`, sort desc, slice to `limit`.
### 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. FTS5 `ORDER BY rank`.
- On FTS5 parse error it falls back to `likeFallbackSearch` — OR-ed `LIKE … ESCAPE '\'` over `content` (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)
- `vectorSearch` embeds the query (`embedder.embed`), converts the `Float32Array` to a blob, and does a `MATCH ? AND k = ?` KNN query on `memory_frames_vec` (`ORDER BY distance`).
- When `gopId` is 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/catch` returning `[]` — if the `sqlite-vec` extension 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.0` if `last_accessed` within `RECENCY_BOOST_DAYS = 7`, else exponential decay with `HALF_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`, a `Map<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:
1. **Ensure session**`sessions.ensureActive()` (transaction-wrapped to avoid the twin-session race).
2. **Save frame** — if a latest I-frame exists for the session → `createPFrame` (delta), else `createIFrame` (keyframe). Dedup applies inside the FrameStore.
3. **Extract entities**`extractEntities(content.slice(0, 10_000))` (from `entity-extractor.ts`).
4. **Upsert entities** into the KG (skip if same type+name exists; cached per-type to avoid N queries).
5. **Co-occurrence relations**`co_occurs_with` (confidence 0.8) between every entity pair found in the same text.
6. **Semantic relations**`extractRelations` → typed relations (`led_by`, `reports_to`, `depends_on`, …) matched back to KG entities.
7. **Vector index**`search.indexFrame(frame.id, content)`.
8. **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`).
```mermaid
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'` and `lastError` tell 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").
```mermaid
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 `FrameSource` union (`frames.ts`) includes `'personal' | 'workspace' | 'team_sync'` which the DB `CHECK` does **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.