# Memory layers

> Repo-specific L0 conversation, L1 atom, L2 scene, and L3 persona model: what each layer stores, how the pipeline schedules L1→L2→L3, and drill-down paths between layers.

- Repository: TencentCloud/TencentDB-Agent-Memory
- GitHub: https://github.com/TencentCloud/TencentDB-Agent-Memory
- Human docs: https://grok-wiki.com/public/docs/tencentcloud-tencentdb-agent-memory-5a33bbf5540a
- Complete Markdown: https://grok-wiki.com/public/docs/tencentcloud-tencentdb-agent-memory-5a33bbf5540a/llms-full.txt

## Source Files

- `src/core/tdai-core.ts`
- `src/core/conversation/l0-recorder.ts`
- `src/core/record/l1-extractor.ts`
- `src/core/scene/scene-extractor.ts`
- `src/core/persona/persona-generator.ts`
- `src/core/types.ts`
- `src/utils/pipeline-factory.ts`

---

---
title: "Memory layers"
description: "Repo-specific L0 conversation, L1 atom, L2 scene, and L3 persona model: what each layer stores, how the pipeline schedules L1→L2→L3, and drill-down paths between layers."
---

TDAI memory is organized into four layers that live under the plugin data directory: L0 raw conversation messages (`conversations/*.jsonl`), L1 extracted memory atoms (`records/*.jsonl` plus the vector store), L2 scene blocks (`scene_blocks/*.md` with a synced `scene_index.json`), and a single L3 persona document (`persona.md`). `MemoryPipelineManager` (`src/utils/pipeline-manager.ts`) schedules the L1→L2→L3 promotion asynchronously after each captured turn, and `TdaiCore` (`src/core/tdai-core.ts`) wires the layer runners identically for the OpenClaw in-process host and the standalone Gateway/Hermes host.

## Layer summary

| Layer | Producer | Storage | Unit | Trigger |
|-------|----------|---------|------|---------|
| L0 | `recordConversation` (`src/core/conversation/l0-recorder.ts`) | `conversations/YYYY-MM-DD.jsonl` + optional L0 vectors | One sanitized message per JSONL line | Every committed turn (`agent_end` / `sync_turn`) |
| L1 | `extractL1Memories` (`src/core/record/l1-extractor.ts`) | `records/YYYY-MM-DD.jsonl` + vector store | Typed memory atom (`persona` / `episodic` / `instruction`) | Conversation threshold, idle timeout, or shutdown flush |
| L2 | `SceneExtractor` (`src/core/scene/scene-extractor.ts`) | `scene_blocks/*.md` + `scene_index.json` | Scene block (META header + Markdown body) | Downward-only timer after L1 completes |
| L3 | `PersonaGenerator` (`src/core/persona/persona-generator.ts`) | `persona.md` | Persona document with appended scene navigation | `PersonaTrigger` after L2 (every N memories or explicit request) |

```mermaid
flowchart TB
  subgraph Host["Host events (OpenClaw hook / Gateway HTTP)"]
    TURN["handleTurnCommitted (agent_end / sync_turn)"]
    RECALL["handleBeforeRecall (before_prompt_build / prefetch)"]
  end

  subgraph Pipeline["MemoryPipelineManager (serial queues L1/L2/L3)"]
    L1R["L1 runner — text-only LLM extraction"]
    L2R["L2 runner — SceneExtractor (tools, sandboxed)"]
    L3R["L3 runner — PersonaTrigger + PersonaGenerator"]
  end

  subgraph Data["dataDir"]
    L0[("conversations/*.jsonl (L0)")]
    L1S[("records/*.jsonl + vector store (L1)")]
    L2S[("scene_blocks/*.md + scene_index.json (L2)")]
    L3S[("persona.md (L3)")]
  end

  TURN -->|"recordConversation + notifyConversation"| L0
  L0 -->|"grouped by sessionId, cursor-incremental"| L1R --> L1S
  L1S -->|"updatedAt cursor"| L2R --> L2S
  L2S -->|"changed scenes since last persona"| L3R --> L3S
  L3S -->|"persona + scene navigation → system prompt"| RECALL
  L1S -->|"hybrid search hits → prepend context"| RECALL
```

## L0 — conversation record

`recordConversation` runs on every committed turn. It captures only incremental messages using two protection layers: a position slice based on `originalUserMessageCount` (the message count cached at `before_prompt_build`, immune to timestamp drift after gateway restarts) and an `afterTimestamp` cursor fallback. The user message that OpenClaw pollutes with injected `prependContext` is replaced with the cached clean `originalUserText`.

Each surviving message is sanitized (`sanitizeText`), assistant replies have fenced code blocks stripped (`stripCodeBlocks`), inline base64 image data URIs are collapsed to `[image]`, and noise is dropped by `shouldCaptureL0`. Records are appended as flat JSONL, one message per line, into a per-day file shared by all sessions:

```json title="conversations/2026-08-04.jsonl (one L0MessageRecord per line)"
{"sessionKey":"agent:main","sessionId":"s-01","recordedAt":"2026-08-04T09:12:33.512Z","id":"msg_1754298753000_a1b2c3","role":"user","content":"...","timestamp":1754298753000}
```

The `id` field is load-bearing: L1 extraction reports `source_message_ids` that point back to these L0 message IDs. When the vector store is available, L0 messages are also indexed for `tdai_conversation_search`; when it is not, L1 falls back to reading the JSONL files directly via `readConversationMessagesGroupedBySessionId` (groups per `sessionId`, since one `sessionKey` can span multiple conversation instances after `/reset`).

## L1 — memory atoms

`extractL1Memories` turns buffered L0 messages into structured atoms with a single text-only LLM call (`taskId: "l1-extraction"`, 180 s timeout). The stages are:

1. **Quality gate** — `shouldExtractL1` filters length/symbol/prompt-injection noise. L0 deliberately captures everything; strictness lives here.
2. **Windowing** — the newest 10 messages are the extraction target, with up to 5 older messages as background context, plus `previousSceneName` for scene continuity across batches.
3. **Scene-segmented extraction** — the LLM returns a JSON array of `{scene_name, message_ids, memories[]}` segments; invalid types are dropped, legacy names normalized (`episode`→`episodic`, `preference`→`persona`).
4. **Batch dedup** — when `extraction.enableDedup` is on (default), `batchDedup` recalls similar existing records (vector top-K via `embedding.conflictRecallTopK`) and decides store/merge per atom; on dedup failure everything is stored as new.
5. **Write** — `writeMemory` appends to `records/YYYY-MM-DD.jsonl` and the vector store.

A stored `MemoryRecord` (`src/core/record/l1-writer.ts`) carries the cross-layer link fields:

<ResponseField name="type" type='"persona" | "episodic" | "instruction"'>Memory category; `priority` is 0–100 (−1 marks a strict global instruction).</ResponseField>
<ResponseField name="scene_name" type="string">Scene assigned during extraction — the upward link toward L2 scene blocks.</ResponseField>
<ResponseField name="source_message_ids" type="string[]">L0 message IDs this atom was distilled from — the downward drill-down link.</ResponseField>
<ResponseField name="sessionKey / sessionId" type="string">Provenance: conversation channel and instance identifiers.</ResponseField>

Per LLM call, at most `extraction.maxMemoriesPerSession` atoms are kept (config default 20).

## L2 — scene blocks

`SceneExtractor.extract()` is an agentic step: the LLM runs with **tools enabled** and its `workspaceDir` sandboxed to `scene_blocks/`, so it can only read and write `.md` scene files — checkpoints, `scene_index.json`, and `persona.md` are physically invisible to it. Each run:

1. Backs up `scene_blocks/` (`persona.sceneBackupCount`, default 10 backups) and restores the backup if the LLM run fails.
2. Feeds new L1 records (incremental via the `updatedAt` cursor tracked by the pipeline manager) plus summaries of existing scenes with a capacity counter. A tiered warning enforces `persona.maxScenes` (default 15): near the limit CREATE is discouraged, at the limit the LLM must MERGE scenes first.
3. Cleans up after the LLM: files containing only `[DELETED]` or an empty META body are removed (the LLM has no `exec` tool; soft-delete markers are its only deletion mechanism), filenames are normalized, and `syncSceneIndex` rebuilds `scene_index.json` from disk.
4. Refreshes the scene-navigation section of `persona.md` and parses the LLM output for an out-of-band `[PERSONA_UPDATE_REQUEST]reason[/PERSONA_UPDATE_REQUEST]` signal, which is persisted to the checkpoint for L3.

Scene files use a META-delimited format parsed by `src/core/scene/scene-format.ts`:

```text title="scene_blocks/<scene>.md"
-----META-START-----
created: 2026-07-30 10:02
updated: 2026-08-04 09:15
summary: One-line scene summary used in navigation and prompts
heat: 120
-----META-END-----

<Markdown scene body: profile, event timeline, stage conclusions>
```

`heat` counts recall hits for the scene and drives navigation ordering.

## L3 — persona

`createL3Runner` first consults `PersonaTrigger` (fires every `persona.triggerEveryN` processed memories, default 50, or on a persisted persona-update request). `PersonaGenerator.generateLocalPersona()` then:

1. Reads the existing `persona.md` (navigation stripped) to pick `first` vs `incremental` mode.
2. Diffs the scene index against `last_persona_time` in the checkpoint and preloads the full content of changed scene blocks into the prompt; with no changes and an existing persona, generation is skipped.
3. Runs the LLM with tools enabled and `workspaceDir` set to the data directory (180 s timeout) — the LLM writes `persona.md` directly.
4. Post-processes: strips any navigation the LLM added, escapes XML-like tags for safe prompt injection, appends fresh scene navigation, and writes the final file. `persona.md` is backed up before each run (`persona.backupCount`, default 3).

## Pipeline scheduling

`MemoryPipelineManager` owns three serial queues (concurrency 1 each) and per-session timers. Config keys live under the `pipeline` group (`src/config.ts`):

| Config key | Default | Role |
|------------|---------|------|
| `pipeline.everyNConversations` | `5` | L1 conversation-count threshold |
| `pipeline.enableWarmup` | `true` | New-session threshold ramps 1 → 2 → 4 → 8 → … → `everyNConversations` |
| `pipeline.l1IdleTimeoutSeconds` | `600` | Resettable idle timer that flushes below-threshold buffers through L1 |
| `pipeline.l2DelayAfterL1Seconds` | `10` | L2 fire-time advance after an L1 completion |
| `pipeline.l2MinIntervalSeconds` | `900` | Floor between L2 runs per session |
| `pipeline.l2MaxIntervalSeconds` | `3600` | Guaranteed L2 poll interval for active sessions |
| `pipeline.sessionActiveWindowHours` | `24` | Sessions idle longer than this stop L2 polling until the next L1 event |

L1 has three trigger paths: the (warm-up-adjusted) conversation threshold, the idle timeout, and a shutdown flush; failed L1 runs retry after 30 s, up to 5 consecutive attempts per session. The L2 timer is **downward-only** — its fire time can move earlier but never later: after L1 completes it advances to `max(now + delayAfterL1, lastL2 + minInterval)`, and after each L2 run it resets to `now + maxInterval`. L3 runs after L2 completes behind a global mutex with a pending-flag dedup, so concurrent requests collapse into one persona generation.

```mermaid
stateDiagram-v2
    [*] --> Buffering : notifyConversation()
    Buffering --> L1 : count ≥ threshold (warm-up 1→2→4→…)
    Buffering --> L1 : idle l1IdleTimeoutSeconds
    Buffering --> L1 : shutdown flush
    L1 --> L2armed : advance timer to max(now+delay, lastL2+minInterval)
    L2armed --> L2 : timer fires (session active)
    L2armed --> Cancelled : session cold > sessionActiveWindowHours
    Cancelled --> L2armed : next L1 event re-arms
    L2 --> L3 : enqueue (global mutex, pending-flag dedup)
    L2 --> L2armed : reset timer to now + maxInterval
    L3 --> [*] : persona.md written, checkpoint advanced
```

Progress is durable: `CheckpointManager` persists per-session L1 cursors (max `recordedAtMs` of processed L0 batches), the last scene name for continuity, L2 `updatedAt` cursors, `scenes_processed` / `total_processed` counters, and `last_persona_time`. `TdaiCore.handleSessionEnd` flushes exactly one session's buffered work without touching other sessions or the shared scheduler; full teardown is `destroy()` only.

<Note>
Runner boundaries differ by layer: L1 (and dedup) use a **text-only** LLM runner (`enableTools: false`), while L2 and L3 require a **tool-enabled** runner (`enableTools: true`). `TdaiCore.wirePipelineRunners` selects the OpenClaw embedded runner or `StandaloneLLMRunnerFactory` (direct OpenAI-compatible HTTP) based on `llm.enabled` and the host type.
</Note>

## Drill-down paths between layers

Recall (`src/core/hooks/auto-recall.ts`) injects the layers top-down and gives the agent explicit paths back down:

- **L3 → prompt**: `persona.md` (persona + scene navigation + a memory-tools guide) becomes the stable `appendSystemContext`; L1 search hits (keyword FTS5 BM25, embedding, or hybrid RRF) become the per-turn `prependContext`.
- **L3 → L2**: the scene-navigation section of `persona.md` lists each scene's absolute `scene_blocks/<file>.md` path, heat, and summary, so the agent can `read_file` a full scene on demand (progressive disclosure).
- **L2 → L1**: scene blocks are consolidations of L1 atoms; `tdai_memory_search` supports a `scene` filter to retrieve the underlying atoms for a scene.
- **L1 → L0**: each `MemoryRecord` carries `source_message_ids` referencing L0 message IDs, and `tdai_conversation_search` retrieves the raw conversation lines for exact wording and timelines.

The injected tools guide caps `tdai_memory_search` + `tdai_conversation_search` at 3 combined calls per turn, and recall as a whole is bounded by `recall.timeoutMs` (default 5000 ms) — on timeout the turn proceeds with no memory injection rather than blocking the user.

## On-disk layout

:::files
dataDir/                      # e.g. ~/.openclaw/memory-tdai/
├── conversations/            # L0 — daily JSONL shards, one message per line
│   └── 2026-08-04.jsonl
├── records/                  # L1 — daily JSONL shards of MemoryRecords
│   └── 2026-08-04.jsonl
├── scene_blocks/             # L2 — META + Markdown scene files (LLM sandbox)
│   └── <scene>.md
├── persona.md                # L3 — persona + scene navigation
├── .metadata/                # scene_index.json, recall_checkpoint.json
└── .backup/                  # scene_blocks/ and persona.md backups
:::

## Related pages

<CardGroup cols={2}>
  <Card title="Storage backends" href="/storage-backends">How L0/L1 vectors, FTS5 BM25, and hybrid RRF retrieval work across sqlite and tcvdb.</Card>
  <Card title="Configure OpenClaw" href="/configure-openclaw">Tune the capture, pipeline, recall, and persona config groups behind these layers.</Card>
  <Card title="Inspect local memory" href="/inspect-local-memory">Query L0–L3 artifacts on disk with read-local-memory and the diagnostic export.</Card>
  <Card title="Agent tools" href="/agent-tools">tdai_memory_search and tdai_conversation_search parameters, strategies, and the 3-call limit.</Card>
  <Card title="Seed historical conversations" href="/seed-history">Run the same L0→L1→L2→L3 path offline over imported conversation JSON.</Card>
  <Card title="Context offload" href="/context-offload">The separate symbolic short-term memory pipelines that complement these layers.</Card>
</CardGroup>
