# Memory Architecture

## Data Model

### Observations (raw events)

An `Observation` is an append-only record of something that happened during a session. Observations are fire-and-forget -- a failed write is silently swallowed because memory is best-effort.

```ts
interface Observation {
  id: string; // Auto-generated on append
  identityId: string; // Owning user
  type: ObservationType; // What kind of event
  content: string; // Human-readable summary
  metadata: Record<string, unknown>; // Structured payload (toolName, entityId, domain, etc.)
  createdAt: number; // Epoch ms, used as sort score
}
```

**ObservationType** values:

| Type            | Captures                                                | Metadata keys                           |
| --------------- | ------------------------------------------------------- | --------------------------------------- |
| `tool_success`  | A tool executed successfully                            | `toolName`, `durationMs`                |
| `tool_failure`  | A tool returned an error                                | `toolName`, `durationMs`, `error`       |
| `entity_access` | An entity was accessed (account, product, schema table) | `entityType`, `entityId`, `entityName?` |
| `query_pattern` | The user's query matched a domain pattern               | `domain`, `query`                       |

Observations are stored in a sorted set keyed by `createdAt` and pruned on a 30-day TTL.

### Facts (consolidated beliefs)

A `Fact` is a durable belief about a user derived from one or more observations. Facts have a confidence score that rises on reinforcement and decays over time.

```ts
interface Fact {
  id: string; // Deterministic: "fact:{subject}"
  identityId: string; // Owning user
  category: FactCategory; // Classification
  subject: string; // Unique key (e.g. "tool_affinity:get_account")
  content: string; // Display text for prompt injection
  confidence: number; // 0.0 - 1.0, decays over time
  reinforcementCount: number; // How many times this fact has been reinforced
  lastReinforcedAt: number; // Epoch ms of last reinforcement
  createdAt: number; // Epoch ms of initial creation
  updatedAt: number; // Epoch ms of last update
  easeFactor?: number; // SM-2 ease factor (AdaptiveDecay only)
}
```

**FactCategory** values:

| Category            | Derived from    | Meaning                                |
| ------------------- | --------------- | -------------------------------------- |
| `tool_affinity`     | `tool_success`  | User benefits from this tool           |
| `tool_avoidance`    | `tool_failure`  | This tool tends to fail for this user  |
| `entity_frequency`  | `entity_access` | User frequently works with this entity |
| `domain_preference` | `query_pattern` | User asks about this domain often      |
| `failure_pattern`   | (reserved)      | Recurring failure patterns             |

## Pipeline

```
                              Session N                            Session N+1
                    ┌──────────────────────────┐          ┌─────────────────────────┐
                    │                          │          │                         │
  User query ──────►  emitQueryPatternObs()    │          │  1. Get checkpoint      │
                    │         │                │          │         │               │
  Tool execution ──►  emitToolObservations()   │          │  2. Fetch obs since     │
                    │         │                │          │         │               │
  Entity access ───►  emitEntityObservations() │          │  3. Group by fact key   │
                    │         │                │          │         │               │
                    │         ▼                │          │  4. Reinforce / create  │
                    │  ObservationLog.append()│          │         │               │
                    │  (fire-and-forget)       │          │  5. Decay unreinforced  │
                    │                          │          │         │               │
                    └──────────────────────────┘          │  6. Delete expired      │
                                                          │         │               │
                                                          │  7. Write checkpoint    │
                                                          │         ▼               │
                                                          │  Active Fact[]          │
                                                          │    │          │         │
                                                          │    ▼          ▼         │
                                                          │  Prompt    Tool         │
                                                          │  section   affinities   │
                                                          └─────────────────────────┘
```

### 1. Observation Emission

Observations are created at two points in the request lifecycle:

- **Query start** -- `emitQueryPatternObservation(query)` matches the user's message against domain regex patterns (royalty, contract, account, product, revenue) and emits a `query_pattern` observation if matched.
- **Tool execution callback** -- `onToolExecution` in the stream handler calls `emitToolObservations()` for every non-meta tool invocation, producing `tool_success` or `tool_failure` observations. `emitEntityObservations()` extracts entity accesses from specific tool results (get_account, search_snowflake_schema, get_products_by_isrc).

All emission goes through `MemoryService.emitObservation()`, which calls `ObservationLog.append()` in a fire-and-forget promise. Failures are swallowed. In-flight promises are tracked for graceful `drain()` on shutdown.

### 2. Consolidation

The `consolidate()` function runs at the start of each session (inline, with a 500ms timeout). The algorithm:

1. **Read checkpoint** -- `checkpoint.get(identityId)` returns the epoch ms of the last consolidation, or `null` for first run.
2. **Fetch new observations** -- `ObservationLog.since(identityId, checkpoint)` returns all observations after the checkpoint.
3. **Short-circuit** -- If no new observations, return existing facts unchanged.
4. **Load existing facts** -- `FactRepository.get(identityId)` returns all facts, indexed by subject.
5. **Collect unique fact keys** -- Each observation is mapped to a `(category, subject)` key via `observationToFactKey()`. Multiple observations that map to the same key are deduplicated — each session contributes at most one reinforcement per fact, because sustained cross-session usage is a stronger signal than burst frequency within a single session.
6. **Reinforce or create** -- For each unique fact key:
   - If a fact with that subject exists, call `decay.reinforce(fact, quality)` which bumps confidence by 0.1 (capped at 1.0) and increments `reinforcementCount`.
   - Otherwise, create a new fact with confidence 0.5 and reinforcementCount 1.
7. **Decay unreinforced facts** -- For facts not reinforced this round, apply `decay.apply(fact, now)` to compute current confidence. If below `expiryThreshold` (0.1), mark for deletion.
8. **Persist** -- `factRepository.upsertBatch()` writes active facts, `factRepository.delete()` removes expired ones, `checkpoint.set()` records the latest observation timestamp.
9. **Prune** -- Remove observations older than `checkpoint - 7 days` to bound storage growth while retaining a buffer for crash recovery.
10. **Return** active facts.

**Idempotency**: The checkpoint is written last. If the process crashes between fact writes and the checkpoint write, the next consolidation will re-process some observations. This produces bounded error (double-reinforcement) rather than data loss.

### 3. Confidence Decay

Two strategies are provided, both implementing `DecayStrategy`:

```ts
interface DecayStrategy {
  apply(fact: Fact, now: number): number; // Compute decayed confidence
  reinforce(fact: Fact, quality: number): Fact; // Return reinforced fact
  readonly expiryThreshold: number; // Below this → delete
}
```

#### ExponentialDecay (simple, default)

Formula: `confidence' = confidence * 0.95^days`

Where `days = (now - lastReinforcedAt) / 86_400_000`.

| Parameter          | Value | Meaning                            |
| ------------------ | ----- | ---------------------------------- |
| `BASE_DECAY`       | 0.95  | Daily retention rate               |
| `REINFORCE_AMOUNT` | 0.1   | Confidence boost per reinforcement |
| `EXPIRY_THRESHOLD` | 0.1   | Facts below this are deleted       |

Half-life: `ln(0.5) / ln(0.95) ~= 13.5 days`. A fact with confidence 0.5 (new) expires after ~31 days without reinforcement. A fact at confidence 1.0 (max) expires after ~45 days.

Reinforcement: `confidence = min(1.0, confidence + 0.1)`, increments `reinforcementCount`, updates `lastReinforcedAt`.

#### AdaptiveDecay (SM-2 inspired)

Formula: `confidence' = confidence * 0.95^(days / easeFactor)`

The ease factor slows decay for high-quality facts (frequently reinforced with high quality) and accelerates it for low-quality ones.

| Parameter          | Value | Meaning                              |
| ------------------ | ----- | ------------------------------------ |
| `DEFAULT_EASE`     | 2.5   | Starting ease factor for new facts   |
| `MIN_EASE`         | 1.3   | Floor -- ease never drops below this |
| `BASE_DECAY`       | 0.95  | Base daily retention rate            |
| `REINFORCE_AMOUNT` | 0.1   | Confidence boost per reinforcement   |
| `EXPIRY_THRESHOLD` | 0.1   | Facts below this are deleted         |

Ease factor adjustment on reinforcement: `newEase = max(1.3, currentEase + 0.1 - (1.0 - quality) * 0.3)`

| Quality       | Ease adjustment | Effect       |
| ------------- | --------------- | ------------ |
| 1.0 (perfect) | +0.1            | Slower decay |
| 0.67          | +0.0            | No change    |
| 0.0 (worst)   | -0.2            | Faster decay |

With `easeFactor = 2.5`, effective half-life is `13.5 * 2.5 = 33.75 days`. With `easeFactor = 1.3` (minimum), half-life is `13.5 * 1.3 = 17.55 days`.

### 4. System Prompt Injection

The server-side `formatMemorySection()` function (in `apps/server/src/ai/memory/prompt-formatter.ts`) converts active facts into a system prompt section:

1. **Rank** facts by `confidence * reinforcementCount` (descending).
2. **Group** by category label (using a fixed display order: tool preferences, frequent entities, domain focus, known issues).
3. **Budget** output to 300 tokens (~225 words). Stop adding groups once the budget is exhausted.
4. **Format** as markdown with a header ("What you know about this user") and a closing instruction.

**Ordering**: The memory section is prepended to the Langfuse-resolved system prompt before being passed to `converseWithTools`. This ensures the LLM sees learned context first, before domain instructions.

### 5. Tool Affinity Boost

`extractToolAffinities()` (in `apps/server/src/ai/memory/affinity.ts`) converts facts into a `Map<string, number>`:

- `tool_affinity` facts produce a positive score (= confidence).
- `tool_avoidance` facts produce a negative score (= -confidence).

These scores flow into `searchCatalog()` (in `apps/server/src/ai/tools/catalog.ts`) as the `toolAffinities` parameter. The boost is `affinity * 2`, meaning a max boost of +2 for a fully-confident affinity fact. This acts as a tiebreaker -- it nudges tool ranking but does not override keyword or glossary relevance.

## Storage

### Interfaces

| Interface        | Purpose                     | Methods                                          |
| ---------------- | --------------------------- | ------------------------------------------------ |
| `ObservationLog` | Append-only observation log | `append()`, `since()`, `prune()`                 |
| `FactRepository` | CRUD for facts per identity | `get()`, `upsert()`, `upsertBatch()`, `delete()` |

Checkpoint storage uses `AsyncMap<string, number>` from `@coda/collections` — no dedicated interface needed.

`ObservationLog` and `FactRepository` have null object implementations (`NullObservationLog`, `NullFactRepository`) that return empty results and silently discard writes.

### Redis Implementation

| Store                 | Redis structure            | Key pattern                      | Data encoding                                        |
| --------------------- | -------------------------- | -------------------------------- | ---------------------------------------------------- |
| `RedisObservationLog` | Sorted set (`SortedStore`) | `coda:memory:obs:{identityId}`   | JSON-serialized `Observation`, scored by `createdAt` |
| `RedisFactRepository` | Hash (`HashStore`)         | `coda:memory:facts:{identityId}` | JSON-serialized `Fact`, field = `fact.id`            |

The Redis stores depend on `@coda/common` storage interfaces (`SortedStore`, `HashStore`, `KeyValueStore`), not a specific Redis client. This allows the server to use in-memory implementations during development and real Redis in production.

### Future: Aurora Migration

Facts are strong candidates for Aurora migration to enable cross-tenant queryability (e.g., "what tools are most effective across all users?"). The `FactRepository` interface is already compatible with a Prisma-backed implementation.

## Graceful Degradation

The memory system is designed to degrade to stateless behavior (identical to pre-memory behavior) when any component is unavailable:

| Failure mode                         | Behavior                                                                                 |
| ------------------------------------ | ---------------------------------------------------------------------------------------- |
| Memory service not configured        | `memoryService` is undefined; stream handler skips all memory logic                      |
| Consolidation timeout (>500ms)       | Promise.race rejects; `loadMemory()` returns empty facts, empty prompt, empty affinities |
| Consolidation error                  | Caught in `loadMemory()`; returns empty results                                          |
| Observation append fails             | Swallowed in `emitObservation()` catch handler                                           |
| Shutdown with in-flight observations | `drain()` awaits all pending appends before process exit                                 |
